Compare commits

..

5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
eb2c560c95 Optimize SearchableDropdown with useMemo for filteredItems
Co-authored-by: nishthaAhujaa <45535788+nishthaAhujaa@users.noreply.github.com>
2026-01-14 12:49:36 +00:00
copilot-swe-agent[bot]
302b13a77c Refactor SearchableDropdown with Fluent UI components and add tests
- Replace native HTML elements with Fluent UI components (Stack, DefaultButton, Text)
- Extract inline styles to SearchableDropdown.styles.ts
- Add comprehensive unit tests (14 test cases)
- Verify behavior consistency with AccountSwitcher tests

Co-authored-by: nishthaAhujaa <45535788+nishthaAhujaa@users.noreply.github.com>
2026-01-14 12:48:04 +00:00
copilot-swe-agent[bot]
ad680d9cbf Initial plan 2026-01-14 12:31:44 +00:00
nishthaAhujaa
14ed7454fc format fix 2026-01-08 12:59:40 +05:30
nishthaAhujaa
387575ae46 Searchable dropdown 2026-01-08 12:37:22 +05:30
114 changed files with 1266 additions and 5165 deletions

View File

@@ -1,20 +1 @@
DATA_EXPLORER_ENDPOINT=https://localhost:1234/hostedExplorer.html
# Azure Test Environment Variables
# These are automatically set by the global-setup.ts script
# Azure resource group containing the test resources
DE_TEST_RESOURCE_GROUP=bchoudhury-e2e-testing
# Azure subscription ID for testing
DE_TEST_SUBSCRIPTION_ID=074d02eb-4d74-486a-b299-b262264d1536
# Prefix used for test resource names
DE_TEST_ACCOUNT_PREFIX=bchoudhury-e2e-
# Access token for NoSQL container copy operations
# This is generated automatically by the setup script
NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN=
# Optional: Set to true to enable debug logging during setup
DEBUG=false
DATA_EXPLORER_ENDPOINT=https://localhost:1234/hostedExplorer.html

View File

@@ -1,71 +0,0 @@
---
name: playwright-test-generator
description: 'Expert agent for generating robust Playwright tests for Cosmos DB. Supports DataExplorer and CopyJob flows. Required Inputs: <test-suite>, <test-name>, <test-file>, <seed-file>, <body>'
tools:
- azure-mcp/search
- playwright/browser_click
- playwright/browser_drag
- playwright/browser_evaluate
- playwright/browser_file_upload
- playwright/browser_handle_dialog
- playwright/browser_hover
- playwright/browser_navigate
- playwright/browser_press_key
- playwright/browser_select_option
- playwright/browser_snapshot
- playwright/browser_type
- playwright/browser_wait_for
- filesystem/read_file
- filesystem/write_file
- filesystem/list_directory
- filesystem/create_directory
model: Claude Sonnet 4
---
You are an expert Playwright Test Generator from given planner specs.
Your specialty is creating tests that match the project's high standards for frame navigation and FluentUI interaction. Your expertise includes functional testing using Playwright
# Pre-Generation Analysis
Before generating code, you MUST:
1. **Analyze Existing Specs:** Examine the provided `<seed-file>` and any referenced test files in the project to learn the specific coding standards, naming conventions, and import structures used.
2. **Identify the Flow Type:** Determine if the scenario is a **DataExplorer** flow or a **CopyJob** flow.
- **DataExplorer Flows:** Use `DataExplorer.open()` in the `beforeEach` or `beforeAll` setup logic.
- **CopyJob Flows:** Use `ContainerCopy.open()` in the `beforeEach` or `beforeAll` setup logic.
# Core Project Standards
- **Authentication:** Assume pre-flight authentication (az login/globalSetup) is complete. Do not generate login steps. The `global-setup` has been added into playwright.config.ts
- **Library Usage:** Prioritize common helper methods from `fx.ts`.
- **Page Hierarchy:**
- `frame`: Parent blade locator (used for overlays, dropdown lists, global portal buttons).
- `wrapper`: Child locator (used for the specific view content/form inputs).
- **Selector Strategy:** Use FluentUI-friendly locators: `getByRole` and `getByLabel`. Also prioritize `getByTestId` where available - check for `testIdAttribute` in playwright.config.ts as "data-test" attributes are configured as "data-testid".
# Step-by-Step Generation Workflow
1. **Plan Parsing:** Extract steps and verification logic from the planner spec.
2. **Setup:** Run `generator_setup_page` using the `<seed-file>`.
3. **Observation:** Use Playwright tools to execute steps. Pay close attention to which elements require the `frame` context vs. the `wrapper` context.
4. **Source Code Generation:**
- **File Naming:** Use fs-friendly names based on the scenario.
- **Structure:** Encapsulate in a `test.describe` matching the test plan group.
- **Code Style:** Match the patterns found in the analyzed existing files exactly.
- **Cleanup:** Ensure the test handles data cleanup to remain idempotent.
# Example Logic (Flow Detection)
```ts
// If CopyJob flow:
test.beforeEach(async ({ page }) => {
await ContainerCopy.open // Standardized entry point
});
(or)
test.beforeAll(async ({ page }) => {
await ContainerCopy.open // Standardized entry point
});
// If DataExplorer flow:
test.beforeEach(async ({ page }) => {
await DataExplorer.open // Standardized entry point
});
(or)
test.beforeAll(async ({ page }) => {
await DataExplorer.open // Standardized entry point
});

View File

@@ -1,61 +0,0 @@
---
name: playwright-test-healer
description: Use this agent when you need to debug and fix failing Playwright tests
tools:
- search
- edit
- playwright-test/browser_console_messages
- playwright-test/browser_evaluate
- playwright-test/browser_generate_locator
- playwright-test/browser_network_requests
- playwright-test/browser_snapshot
- playwright-test/test_debug
- playwright-test/test_list
- playwright-test/test_run
model: Claude Sonnet 4
mcp-servers:
playwright-test:
type: stdio
command: npx
args:
- playwright
- run-test-mcp-server
tools:
- "*"
---
You are the Playwright Test Healer, an expert test automation engineer specializing in debugging and resolving Playwright test failures. Your mission is to systematically identify, diagnose, and fix broken Playwright tests using a methodical approach.
Your workflow:
1. **Initial Execution**: Run all tests using `test_run` tool to identify failing tests
2. **Debug failed tests**: For each failing test run `test_debug`.
3. **Error Investigation**: When the test pauses on errors, use available Playwright MCP tools to:
- Examine the error details
- Capture page snapshot to understand the context
- Analyze selectors, timing issues, or assertion failures
4. **Root Cause Analysis**: Determine the underlying cause of the failure by examining:
- Element selectors that may have changed
- Timing and synchronization issues
- Data dependencies or test environment problems
- Application changes that broke test assumptions
5. **Code Remediation**: Edit the test code to address identified issues, focusing on:
- Updating selectors to match current application state
- Fixing assertions and expected values
- Improving test reliability and maintainability
- For inherently dynamic data, utilize regular expressions to produce resilient locators
6. **Verification**: Restart the test after each fix to validate the changes
7. **Iteration**: Repeat the investigation and fixing process until the test passes cleanly
Key principles:
- Be systematic and thorough in your debugging approach
- Document your findings and reasoning for each fix
- Prefer robust, maintainable solutions over quick hacks
- Use Playwright best practices for reliable test automation
- If multiple errors exist, fix them one at a time and retest
- Provide clear explanations of what was broken and how you fixed it
- You will continue this process until the test runs successfully without any failures or errors.
- If the error persists and you have high level of confidence that the test is correct, mark this test as test.fixme()
so that it is skipped during the execution. Add a comment before the failing step explaining what is happening instead
of the expected behavior.
- Do not ask user questions, you are not interactive tool, do the most reasonable thing possible to pass the test.
- Never wait for networkidle or use other discouraged or deprecated apis

View File

@@ -1,78 +0,0 @@
---
name: playwright-test-planner
description: Use this agent when you need to create comprehensive test plan for a web application or website
tools:
- search
- playwright-test/browser_click
- playwright-test/browser_close
- playwright-test/browser_console_messages
- playwright-test/browser_drag
- playwright-test/browser_evaluate
- playwright-test/browser_file_upload
- playwright-test/browser_handle_dialog
- playwright-test/browser_hover
- playwright-test/browser_navigate
- playwright-test/browser_navigate_back
- playwright-test/browser_network_requests
- playwright-test/browser_press_key
- playwright-test/browser_select_option
- playwright-test/browser_snapshot
- playwright-test/browser_take_screenshot
- playwright-test/browser_type
- playwright-test/browser_wait_for
- playwright-test/planner_setup_page
- playwright-test/planner_save_plan
model: Claude Sonnet 4
mcp-servers:
playwright-test:
type: stdio
command: npx
args:
- playwright
- run-test-mcp-server
tools:
- "*"
---
You are an expert web test planner with extensive experience in quality assurance, user experience testing, and test scenario design. Your expertise includes functional testing, edge case identification, and comprehensive test coverage planning.
You will:
1. **Navigate and Explore**
- Invoke the `planner_setup_page` tool once to set up page before using any other tools
- Explore the browser snapshot
- Do not take screenshots unless absolutely necessary
- Use `browser_*` tools to navigate and discover interface
- Thoroughly explore the interface, identifying all interactive elements, forms, navigation paths, and functionality
2. **Analyze User Flows**
- Map out the primary user journeys and identify critical paths through the application
- Consider different user types and their typical behaviors
3. **Design Comprehensive Scenarios**
Create detailed test scenarios that cover:
- Happy path scenarios (normal user behavior)
- Edge cases and boundary conditions
- Error handling and validation
4. **Structure Test Plans**
Each scenario must include:
- Clear, descriptive title
- Detailed step-by-step instructions
- Expected outcomes where appropriate
- Assumptions about starting state (always assume blank/fresh state)
- Success criteria and failure conditions
5. **Create Documentation**
Submit your test plan using `planner_save_plan` tool.
**Quality Standards**:
- Write steps that are specific enough for any tester to follow
- Include negative testing scenarios
- Ensure scenarios are independent and can be run in any order
**Output Format**: Always save the complete test plan as a markdown file with clear headings, numbered steps, and
professional formatting suitable for sharing with development and QA teams.

View File

@@ -192,29 +192,24 @@ jobs:
NOSQL_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-readonly.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$NOSQL_READONLY_TESTACCOUNT_TOKEN"
echo NOSQL_READONLY_TESTACCOUNT_TOKEN=$NOSQL_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV
NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-containercopyonly.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN"
echo NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN=$NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN >> $GITHUB_ENV
TABLE_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-tables.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$TABLE_TESTACCOUNT_TOKEN"
echo TABLE_TESTACCOUNT_TOKEN=$TABLE_TESTACCOUNT_TOKEN >> $GITHUB_ENV
GREMLIN_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-gremlin.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$GREMLIN_TESTACCOUNT_TOKEN"
echo GREMLIN_TESTACCOUNT_TOKEN=$GREMLIN_TESTACCOUNT_TOKEN >> $GITHUB_ENV
CASSANDRA_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-cassandra.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$CASSANDRA_TESTACCOUNT_TOKEN"
echo CASSANDRA_TESTACCOUNT_TOKEN=$CASSANDRA_TESTACCOUNT_TOKEN >> $GITHUB_ENV
MONGO_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$MONGO_TESTACCOUNT_TOKEN"
echo MONGO_TESTACCOUNT_TOKEN=$MONGO_TESTACCOUNT_TOKEN >> $GITHUB_ENV
MONGO32_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo32.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$MONGO32_TESTACCOUNT_TOKEN"
echo MONGO32_TESTACCOUNT_TOKEN=$MONGO32_TESTACCOUNT_TOKEN >> $GITHUB_ENV
MONGO_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo-readonly.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$MONGO_READONLY_TESTACCOUNT_TOKEN"
echo MONGO_READONLY_TESTACCOUNT_TOKEN=$MONGO_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV
- name: List test files for shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}}
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --list
# CASSANDRA_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-cassandra.documents.azure.com/.default" -o tsv --query accessToken)
# echo "::add-mask::$CASSANDRA_TESTACCOUNT_TOKEN"
# echo CASSANDRA_TESTACCOUNT_TOKEN=$CASSANDRA_TESTACCOUNT_TOKEN >> $GITHUB_ENV
# MONGO_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo.documents.azure.com/.default" -o tsv --query accessToken)
# echo "::add-mask::$MONGO_TESTACCOUNT_TOKEN"
# echo MONGO_TESTACCOUNT_TOKEN=$MONGO_TESTACCOUNT_TOKEN >> $GITHUB_ENV
# MONGO32_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo32.documents.azure.com/.default" -o tsv --query accessToken)
# echo "::add-mask::$MONGO32_TESTACCOUNT_TOKEN"
# echo MONGO32_TESTACCOUNT_TOKEN=$MONGO32_TESTACCOUNT_TOKEN >> $GITHUB_ENV
# MONGO_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo-readonly.documents.azure.com/.default" -o tsv --query accessToken)
# echo "::add-mask::$MONGO_READONLY_TESTACCOUNT_TOKEN"
# echo MONGO_READONLY_TESTACCOUNT_TOKEN=$MONGO_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV
- name: Run test shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}}
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --workers=3
- name: Upload blob report to GitHub Actions Artifacts

View File

@@ -1,34 +0,0 @@
name: "Copilot Setup Steps"
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yml
jobs:
copilot-setup-steps:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
# Customize this step as needed
- name: Build application
run: npx run build

1
.gitignore vendored
View File

@@ -21,4 +21,3 @@ GettingStarted-ignore*.ipynb
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/

23
.vscode/mcp.json vendored
View File

@@ -1,23 +0,0 @@
{
"servers": {
"playwright": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@playwright/mcp@latest"],
"env": {
"ALLOWED_DIRECTORIES": "C:/Users/bchoudhury/MyProjects/cosmos-explorer"
}
},
"filesystem": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem@latest",
"C:/Users/bchoudhury/MyProjects/cosmos-explorer/test",
"C:/Users/bchoudhury/MyProjects/cosmos-explorer/src"
]
}
},
"inputs": []
}

View File

@@ -406,11 +406,7 @@ body {
width: 440px;
min-height: 565px;
}
.dataExplorerLoaderforcopyJobs{
width: 100%;
min-height: 565px;
right: 0;
}
.dataExplorerTabLoaderContainer {
left: initial;
top: initial;

27
package-lock.json generated
View File

@@ -125,7 +125,7 @@
"@babel/preset-env": "7.24.7",
"@babel/preset-react": "7.24.7",
"@babel/preset-typescript": "7.24.7",
"@playwright/test": "1.57.0",
"@playwright/test": "1.49.1",
"@testing-library/react": "11.2.3",
"@types/applicationinsights-js": "1.0.7",
"@types/codemirror": "0.0.56",
@@ -10321,13 +10321,12 @@
}
},
"node_modules/@playwright/test": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz",
"integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.57.0"
"playwright": "1.49.1"
},
"bin": {
"playwright": "cli.js"
@@ -30904,13 +30903,12 @@
}
},
"node_modules/playwright": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.57.0"
"playwright-core": "1.49.1"
},
"bin": {
"playwright": "cli.js"
@@ -30923,11 +30921,10 @@
}
},
"node_modules/playwright-core": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},

View File

@@ -111,8 +111,8 @@
"tinykeys": "2.1.0",
"underscore": "1.12.1",
"utility-types": "3.10.0",
"uuid": "9.0.0",
"web-vitals": "4.2.4",
"uuid": "9.0.0",
"zustand": "3.5.0"
},
"devDependencies": {
@@ -120,7 +120,7 @@
"@babel/preset-env": "7.24.7",
"@babel/preset-react": "7.24.7",
"@babel/preset-typescript": "7.24.7",
"@playwright/test": "1.57.0",
"@playwright/test": "1.49.1",
"@testing-library/react": "11.2.3",
"@types/applicationinsights-js": "1.0.7",
"@types/codemirror": "0.0.56",
@@ -213,11 +213,7 @@
"copyToConsumers": "node copyToConsumers",
"test": "rimraf coverage && jest",
"test:debug": "jest --runInBand",
"test:e2e": "npx playwright test",
"test:e2e:setup:verify": "npx playwright test test/setup-verification.spec.ts",
"test:e2e:setup": "node setup-tests.js",
"test:e2e:headed": "npx playwright test --headed",
"test:e2e:ui": "npx playwright test --ui",
"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/",

View File

@@ -6,17 +6,15 @@ export default defineConfig({
testDir: "test",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 3 : 2,
retries: process.env.CI ? 3 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? "blob" : "html",
timeout: 10 * 60 * 1000,
globalSetup: require.resolve('./test/global-setup.ts'),
use: {
trace: "retain-on-failure",
video: "retain-on-failure",
screenshot: "on",
testIdAttribute: "data-test",
// storageState: './test/../playwright/.auth/user.json',
contextOptions: {
ignoreHTTPSErrors: true,
},

View File

@@ -1,48 +0,0 @@
#!/usr/bin/env node
/**
* Manual setup script to run the pre-flight Azure authentication and configuration
* This can be used independently of Playwright for setting up the testing environment
*/
const { exec } = require('child_process');
const { promisify } = require('util');
const path = require('path');
const execAsync = promisify(exec);
async function runManualSetup() {
console.log('🚀 Starting manual Azure Cosmos DB Explorer test setup...');
try {
// Import the global setup function and run it
const globalSetup = require('./test/global-setup.js');
// Create a minimal config object that matches what Playwright would pass
const mockConfig = {
configFile: path.join(__dirname, 'playwright.config.ts'),
rootDir: __dirname,
testDir: path.join(__dirname, 'test'),
projects: []
};
await globalSetup(mockConfig);
console.log('✅ Manual setup completed successfully!');
console.log('\nYou can now run your Playwright tests with:');
console.log(' npm run test:e2e');
console.log(' or');
console.log(' npx playwright test');
} catch (error) {
console.error('❌ Manual setup failed:', error.message);
process.exit(1);
}
}
// Only run if this script is executed directly
if (require.main === module) {
runManualSetup();
}
module.exports = { runManualSetup };

View File

@@ -1,48 +0,0 @@
#!/usr/bin/env node
/**
* Manual setup script to run the pre-flight Azure authentication and configuration
* This can be used independently of Playwright for setting up the testing environment
*/
async function runManualSetup(): Promise<void> {
console.log('🚀 Starting manual Azure Cosmos DB Explorer test setup...');
try {
// Dynamically import the Playwright global setup
const globalSetupModule = await import('./test/global-setup');
const globalSetup = globalSetupModule.default;
if (typeof globalSetup !== 'function') {
throw new Error('global-setup.ts does not export a default function');
}
// Minimal mock config similar to what Playwright provides
/* const mockConfig: FullConfig = {
configFile: path.join(__dirname, 'playwright.config.ts'),
rootDir: __dirname,
testDir: path.join(__dirname, 'test'),
projects: []
}; */
await globalSetup();
console.log('✅ Manual setup completed successfully!');
console.log('\nYou can now run your Playwright tests with:');
console.log(' npm run test:e2e');
console.log(' or');
console.log(' npx playwright test');
} catch (error: any) {
console.error('❌ Manual setup failed:', error.message);
process.exit(1);
}
}
// Only run if executed directly (not imported)
if (require.main === module) {
void runManualSetup();
}
export { runManualSetup };

View File

@@ -1,5 +1,4 @@
import { Overlay, Spinner, SpinnerSize } from "@fluentui/react";
import { useThemeStore } from "hooks/useTheme";
import React from "react";
interface LoadingOverlayProps {
@@ -8,7 +7,6 @@ interface LoadingOverlayProps {
}
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ isLoading, label }) => {
const isDarkMode = useThemeStore((state) => state.isDarkMode);
if (!isLoading) {
return null;
}
@@ -18,7 +16,7 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ isLoading, label }) =>
data-test="loading-overlay"
styles={{
root: {
backgroundColor: isDarkMode ? "rgba(32, 31, 30, 0.9)" : "rgba(255,255,255,0.9)",
backgroundColor: "rgba(255,255,255,0.9)",
zIndex: 9999,
display: "flex",
alignItems: "center",
@@ -26,11 +24,7 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ isLoading, label }) =>
},
}}
>
<Spinner
size={SpinnerSize.large}
label={label}
styles={{ label: { fontWeight: 600, color: isDarkMode ? "#ffffff" : "#323130" } }}
/>
<Spinner size={SpinnerSize.large} label={label} styles={{ label: { fontWeight: 600 } }} />
</Overlay>
);
};

View File

@@ -11,14 +11,3 @@
gap: 8px;
align-items: center;
}
/* Override dark mode inherit for pagination icons */
body.isDarkMode .pager-container .ms-Button .ms-Button-icon,
body.isDarkMode .pager-container .ms-Button i {
color: var(--colorBrandForeground1);
}
body.isDarkMode .pager-container .ms-Button:disabled .ms-Button-icon,
body.isDarkMode .pager-container .ms-Button:disabled i {
color: var(--colorNeutralForegroundDisabled);
}

View File

@@ -59,7 +59,7 @@ const Pager: React.FC<PagerProps> = ({
return (
<div className={className || "pager-container"}>
{showItemCount && (
<Text className="themeText">
<Text>
Showing {startIndex + 1} - {endIndex} of {totalCount} items
</Text>
)}
@@ -82,7 +82,7 @@ const Pager: React.FC<PagerProps> = ({
disabled={disabled || currentPage === 1}
styles={iconButtonStyles}
/>
<Text className="themeText">
<Text>
Page {currentPage} of {totalPages}
</Text>
<IconButton

View File

@@ -0,0 +1,65 @@
import { IButtonStyles, IStackStyles, ITextStyles } from "@fluentui/react";
import * as React from "react";
export const getDropdownButtonStyles = (disabled: boolean): IButtonStyles => ({
root: {
width: "100%",
height: "32px",
padding: "0 28px 0 8px",
border: "1px solid #8a8886",
background: "#fff",
color: "#323130",
textAlign: "left",
cursor: disabled ? "not-allowed" : "pointer",
position: "relative",
},
label: {
fontWeight: "normal",
fontSize: "14px",
},
});
export const buttonLabelStyles: ITextStyles = {
root: {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
display: "block",
},
};
export const chevronStyles: React.CSSProperties = {
position: "absolute",
right: "8px",
top: "50%",
transform: "translateY(-50%)",
pointerEvents: "none",
};
export const calloutContentStyles: IStackStyles = {
root: {
display: "flex",
flexDirection: "column",
},
};
export const listContainerStyles: IStackStyles = {
root: {
maxHeight: "300px",
overflowY: "auto",
},
};
export const getItemStyles = (isSelected: boolean): React.CSSProperties => ({
padding: "8px 12px",
cursor: "pointer",
fontSize: "14px",
backgroundColor: isSelected ? "#e6e6e6" : "transparent",
});
export const emptyMessageStyles: ITextStyles = {
root: {
padding: "8px 12px",
color: "#605e5c",
},
};

View File

@@ -0,0 +1,200 @@
import { fireEvent, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import React from "react";
import { SearchableDropdown } from "./SearchableDropdown";
interface TestItem {
id: string;
name: string;
}
describe("SearchableDropdown", () => {
const mockItems: TestItem[] = [
{ id: "1", name: "Item One" },
{ id: "2", name: "Item Two" },
{ id: "3", name: "Item Three" },
];
const defaultProps = {
label: "Test Label",
items: mockItems,
selectedItem: null,
onSelect: jest.fn(),
getKey: (item: TestItem) => item.id,
getDisplayText: (item: TestItem) => item.name,
placeholder: "Select an item",
filterPlaceholder: "Filter items",
className: "test-dropdown",
};
beforeEach(() => {
jest.clearAllMocks();
});
it("should render with label and placeholder", () => {
render(<SearchableDropdown {...defaultProps} />);
expect(screen.getByText("Test Label")).toBeInTheDocument();
expect(screen.getByText("Select an item")).toBeInTheDocument();
});
it("should display selected item", () => {
const propsWithSelection = {
...defaultProps,
selectedItem: mockItems[0],
};
render(<SearchableDropdown {...propsWithSelection} />);
expect(screen.getByText("Item One")).toBeInTheDocument();
});
it("should show 'No items found' when items array is empty", () => {
const propsWithEmptyItems = {
...defaultProps,
items: [],
};
render(<SearchableDropdown {...propsWithEmptyItems} />);
expect(screen.getByText("No Test Labels Found")).toBeInTheDocument();
});
it("should open dropdown when button is clicked", () => {
render(<SearchableDropdown {...defaultProps} />);
const button = screen.getByText("Select an item");
fireEvent.click(button);
expect(screen.getByPlaceholderText("Filter items")).toBeInTheDocument();
});
it("should filter items based on search text", () => {
render(<SearchableDropdown {...defaultProps} />);
const button = screen.getByText("Select an item");
fireEvent.click(button);
const searchBox = screen.getByPlaceholderText("Filter items");
fireEvent.change(searchBox, { target: { value: "Two" } });
expect(screen.getByText("Item Two")).toBeInTheDocument();
expect(screen.queryByText("Item One")).not.toBeInTheDocument();
expect(screen.queryByText("Item Three")).not.toBeInTheDocument();
});
it("should call onSelect when an item is clicked", () => {
const onSelectMock = jest.fn();
const propsWithMock = {
...defaultProps,
onSelect: onSelectMock,
};
render(<SearchableDropdown {...propsWithMock} />);
const button = screen.getByText("Select an item");
fireEvent.click(button);
const item = screen.getByText("Item Two");
fireEvent.click(item);
expect(onSelectMock).toHaveBeenCalledWith(mockItems[1]);
});
it("should close dropdown after selecting an item", () => {
render(<SearchableDropdown {...defaultProps} />);
const button = screen.getByText("Select an item");
fireEvent.click(button);
expect(screen.getByPlaceholderText("Filter items")).toBeInTheDocument();
const item = screen.getByText("Item One");
fireEvent.click(item);
expect(screen.queryByPlaceholderText("Filter items")).not.toBeInTheDocument();
});
it("should disable button when disabled prop is true", () => {
const propsWithDisabled = {
...defaultProps,
disabled: true,
};
render(<SearchableDropdown {...propsWithDisabled} />);
const button = screen.getByRole("button");
expect(button).toBeDisabled();
});
it("should not open dropdown when disabled", () => {
const propsWithDisabled = {
...defaultProps,
disabled: true,
};
render(<SearchableDropdown {...propsWithDisabled} />);
const button = screen.getByRole("button");
fireEvent.click(button);
expect(screen.queryByPlaceholderText("Filter items")).not.toBeInTheDocument();
});
it("should show 'No items found' when search yields no results", () => {
render(<SearchableDropdown {...defaultProps} />);
const button = screen.getByText("Select an item");
fireEvent.click(button);
const searchBox = screen.getByPlaceholderText("Filter items");
fireEvent.change(searchBox, { target: { value: "Nonexistent" } });
expect(screen.getByText("No items found")).toBeInTheDocument();
});
it("should handle case-insensitive filtering", () => {
render(<SearchableDropdown {...defaultProps} />);
const button = screen.getByText("Select an item");
fireEvent.click(button);
const searchBox = screen.getByPlaceholderText("Filter items");
fireEvent.change(searchBox, { target: { value: "two" } });
expect(screen.getByText("Item Two")).toBeInTheDocument();
expect(screen.queryByText("Item One")).not.toBeInTheDocument();
});
it("should clear filter text when dropdown is closed and reopened", () => {
render(<SearchableDropdown {...defaultProps} />);
const button = screen.getByText("Select an item");
fireEvent.click(button);
const searchBox = screen.getByPlaceholderText("Filter items");
fireEvent.change(searchBox, { target: { value: "Two" } });
// Close dropdown by selecting an item
const item = screen.getByText("Item Two");
fireEvent.click(item);
// Reopen dropdown
fireEvent.click(button);
// Filter text should be cleared
const reopenedSearchBox = screen.getByPlaceholderText("Filter items");
expect(reopenedSearchBox).toHaveValue("");
});
it("should use custom placeholder text", () => {
const propsWithCustomPlaceholder = {
...defaultProps,
placeholder: "Choose an option",
};
render(<SearchableDropdown {...propsWithCustomPlaceholder} />);
expect(screen.getByText("Choose an option")).toBeInTheDocument();
});
it("should use custom filter placeholder text", () => {
const propsWithCustomFilterPlaceholder = {
...defaultProps,
filterPlaceholder: "Search here",
};
render(<SearchableDropdown {...propsWithCustomFilterPlaceholder} />);
const button = screen.getByText("Select an item");
fireEvent.click(button);
expect(screen.getByPlaceholderText("Search here")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,155 @@
import {
Callout,
DefaultButton,
DirectionalHint,
ISearchBoxStyles,
Label,
SearchBox,
Stack,
Text,
} from "@fluentui/react";
import * as React from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import {
buttonLabelStyles,
calloutContentStyles,
chevronStyles,
emptyMessageStyles,
getDropdownButtonStyles,
getItemStyles,
listContainerStyles,
} from "./SearchableDropdown.styles";
interface SearchableDropdownProps<T> {
label: string;
items: T[];
selectedItem: T | null;
onSelect: (item: T) => void;
getKey: (item: T) => string;
getDisplayText: (item: T) => string;
placeholder?: string;
filterPlaceholder?: string;
className?: string;
disabled?: boolean;
onDismiss?: () => void;
searchBoxStyles?: Partial<ISearchBoxStyles>;
}
export const SearchableDropdown = <T,>({
label,
items,
selectedItem,
onSelect,
getKey,
getDisplayText,
placeholder = "Select an item",
filterPlaceholder = "Filter items",
className,
disabled = false,
onDismiss,
searchBoxStyles: customSearchBoxStyles,
}: SearchableDropdownProps<T>): React.ReactElement => {
const [isOpen, setIsOpen] = useState(false);
const [filterText, setFilterText] = useState("");
const buttonRef = useRef<HTMLDivElement>(null);
const closeDropdown = useCallback(() => {
setIsOpen(false);
setFilterText("");
}, []);
const filteredItems = useMemo(
() => items?.filter((item) => getDisplayText(item).toLowerCase().includes(filterText.toLowerCase())),
[items, filterText, getDisplayText],
);
const handleDismiss = useCallback(() => {
closeDropdown();
onDismiss?.();
}, [closeDropdown, onDismiss]);
const handleButtonClick = useCallback(() => {
if (disabled) {
return;
}
setIsOpen(!isOpen);
}, [isOpen, disabled]);
const handleSelect = useCallback(
(item: T) => {
onSelect(item);
closeDropdown();
},
[onSelect, closeDropdown],
);
const buttonLabel = selectedItem
? getDisplayText(selectedItem)
: items?.length === 0
? `No ${label}s Found`
: placeholder;
const buttonId = `${className}-button`;
const buttonStyles = getDropdownButtonStyles(disabled);
return (
<Stack>
<Label htmlFor={buttonId}>{label}</Label>
<div ref={buttonRef}>
<DefaultButton
id={buttonId}
className={className}
onClick={handleButtonClick}
styles={buttonStyles}
disabled={disabled}
>
<Text styles={buttonLabelStyles}>{buttonLabel}</Text>
<span style={chevronStyles}></span>
</DefaultButton>
</div>
{isOpen && (
<Callout
target={buttonRef.current}
onDismiss={handleDismiss}
directionalHint={DirectionalHint.bottomLeftEdge}
isBeakVisible={false}
gapSpace={0}
setInitialFocus
>
<Stack styles={calloutContentStyles} style={{ width: buttonRef.current?.offsetWidth || 300 }}>
<SearchBox
placeholder={filterPlaceholder}
value={filterText}
onChange={(_, newValue) => setFilterText(newValue || "")}
styles={customSearchBoxStyles}
/>
<Stack styles={listContainerStyles}>
{filteredItems && filteredItems.length > 0 ? (
filteredItems.map((item) => {
const key = getKey(item);
const isSelected = selectedItem ? getKey(selectedItem) === key : false;
return (
<div
key={key}
onClick={() => handleSelect(item)}
style={getItemStyles(isSelected)}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "#f3f2f1")}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = isSelected ? "#e6e6e6" : "transparent")
}
>
<Text>{getDisplayText(item)}</Text>
</div>
);
})
) : (
<Text styles={emptyMessageStyles}>No items found</Text>
)}
</Stack>
</Stack>
</Callout>
)}
</Stack>
);
};

View File

@@ -9,7 +9,7 @@ import {
SqlStoredProcedureCreateUpdateParameters,
SqlStoredProcedureResource,
} from "../../Utils/arm/generatedClients/cosmos/types";
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { client } from "../CosmosClient";
import { handleError } from "../ErrorHandlingUtils";
@@ -20,7 +20,6 @@ export async function createStoredProcedure(
): Promise<StoredProcedureDefinition & Resource> {
const clearMessage = logConsoleProgress(`Creating stored procedure ${storedProcedure.id}`);
try {
let resource: StoredProcedureDefinition & Resource;
if (
userContext.authType === AuthType.AAD &&
!userContext.features.enableSDKoperations &&
@@ -61,16 +60,14 @@ export async function createStoredProcedure(
storedProcedure.id,
createSprocParams,
);
resource = rpResponse && (rpResponse.properties?.resource as StoredProcedureDefinition & Resource);
} else {
const response = await client()
.database(databaseId)
.container(collectionId)
.scripts.storedProcedures.create(storedProcedure);
resource = response.resource;
return rpResponse && (rpResponse.properties?.resource as StoredProcedureDefinition & Resource);
}
logConsoleInfo(`Successfully created stored procedure ${storedProcedure.id}`);
return resource;
const response = await client()
.database(databaseId)
.container(collectionId)
.scripts.storedProcedures.create(storedProcedure);
return response?.resource;
} catch (error) {
handleError(error, "CreateStoredProcedure", `Error while creating stored procedure ${storedProcedure.id}`);
throw error;

View File

@@ -3,7 +3,7 @@ import { AuthType } from "../../AuthType";
import { userContext } from "../../UserContext";
import { createUpdateSqlTrigger, getSqlTrigger } from "../../Utils/arm/generatedClients/cosmos/sqlResources";
import { SqlTriggerCreateUpdateParameters, SqlTriggerResource } from "../../Utils/arm/generatedClients/cosmos/types";
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { client } from "../CosmosClient";
import { handleError } from "../ErrorHandlingUtils";
@@ -14,7 +14,6 @@ export async function createTrigger(
): Promise<TriggerDefinition | SqlTriggerResource> {
const clearMessage = logConsoleProgress(`Creating trigger ${trigger.id}`);
try {
let resource: SqlTriggerResource | TriggerDefinition;
if (
userContext.authType === AuthType.AAD &&
!userContext.features.enableSDKoperations &&
@@ -53,16 +52,14 @@ export async function createTrigger(
trigger.id,
createTriggerParams,
);
resource = rpResponse && rpResponse.properties?.resource;
} else {
const sdkResponse = await client()
.database(databaseId)
.container(collectionId)
.scripts.triggers.create(trigger as unknown as TriggerDefinition); // TODO: TypeScript does not like the SQL SDK trigger type
resource = sdkResponse.resource;
return rpResponse && rpResponse.properties?.resource;
}
logConsoleInfo(`Successfully created trigger ${trigger.id}`);
return resource;
const response = await client()
.database(databaseId)
.container(collectionId)
.scripts.triggers.create(trigger as unknown as TriggerDefinition); // TODO: TypeScript does not like the SQL SDK trigger type
return response.resource;
} catch (error) {
handleError(error, "CreateTrigger", `Error while creating trigger ${trigger.id}`);
throw error;

View File

@@ -9,7 +9,7 @@ import {
SqlUserDefinedFunctionCreateUpdateParameters,
SqlUserDefinedFunctionResource,
} from "../../Utils/arm/generatedClients/cosmos/types";
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { client } from "../CosmosClient";
import { handleError } from "../ErrorHandlingUtils";
@@ -20,7 +20,6 @@ export async function createUserDefinedFunction(
): Promise<UserDefinedFunctionDefinition & Resource> {
const clearMessage = logConsoleProgress(`Creating user defined function ${userDefinedFunction.id}`);
try {
let resource: UserDefinedFunctionDefinition & Resource;
if (
userContext.authType === AuthType.AAD &&
!userContext.features.enableSDKoperations &&
@@ -61,17 +60,14 @@ export async function createUserDefinedFunction(
userDefinedFunction.id,
createUDFParams,
);
resource = rpResponse && (rpResponse.properties?.resource as UserDefinedFunctionDefinition & Resource);
} else {
const response = await client()
.database(databaseId)
.container(collectionId)
.scripts.userDefinedFunctions.create(userDefinedFunction);
resource = response.resource;
return rpResponse && (rpResponse.properties?.resource as UserDefinedFunctionDefinition & Resource);
}
logConsoleInfo(`Successfully created user defined function ${userDefinedFunction.id}`);
return resource;
const response = await client()
.database(databaseId)
.container(collectionId)
.scripts.userDefinedFunctions.create(userDefinedFunction);
return response?.resource;
} catch (error) {
handleError(
error,

View File

@@ -28,7 +28,6 @@ export async function deleteStoredProcedure(
} else {
await client().database(databaseId).container(collectionId).scripts.storedProcedure(storedProcedureId).delete();
}
logConsoleProgress(`Successfully deleted stored procedure ${storedProcedureId}`);
} catch (error) {
handleError(error, "DeleteStoredProcedure", `Error while deleting stored procedure ${storedProcedureId}`);
throw error;

View File

@@ -24,7 +24,6 @@ export async function deleteTrigger(databaseId: string, collectionId: string, tr
} else {
await client().database(databaseId).container(collectionId).scripts.trigger(triggerId).delete();
}
logConsoleProgress(`Successfully deleted trigger ${triggerId}`);
} catch (error) {
handleError(error, "DeleteTrigger", `Error while deleting trigger ${triggerId}`);
throw error;

View File

@@ -24,7 +24,6 @@ export async function deleteUserDefinedFunction(databaseId: string, collectionId
} else {
await client().database(databaseId).container(collectionId).scripts.userDefinedFunction(id).delete();
}
logConsoleProgress(`Successfully deleted user defined function ${id}`);
} catch (error) {
handleError(error, "DeleteUserDefinedFunction", `Error while deleting user defined function ${id}`);
throw error;

View File

@@ -1,6 +1,5 @@
import "@testing-library/jest-dom";
import Explorer from "Explorer/Explorer";
import { getDataTransferJobs } from "../../../Common/dataAccess/dataTransfers";
import * as Logger from "../../../Common/Logger";
import { useSidePanel } from "../../../hooks/useSidePanel";
import * as dataTransferService from "../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs";
@@ -31,7 +30,6 @@ jest.mock("../../../Common/Logger");
jest.mock("../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs");
jest.mock("../MonitorCopyJobs/MonitorCopyJobRefState");
jest.mock("../CopyJobUtils");
jest.mock("../../../Common/dataAccess/dataTransfers");
describe("CopyJobActions", () => {
beforeEach(() => {
@@ -156,31 +154,33 @@ describe("CopyJobActions", () => {
});
it("should fetch and format copy jobs successfully", async () => {
const mockResponse = [
{
properties: {
jobName: "job-1",
status: "InProgress",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 50,
totalCount: 100,
mode: "online",
duration: "01:30:45",
source: {
component: "CosmosDBSql",
databaseName: "source-db",
containerName: "source-container",
},
destination: {
component: "CosmosDBSql",
databaseName: "target-db",
containerName: "target-container",
const mockResponse = {
value: [
{
properties: {
jobName: "job-1",
status: "InProgress",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 50,
totalCount: 100,
mode: "online",
duration: "01:30:45",
source: {
component: "CosmosDBSql",
databaseName: "source-db",
containerName: "source-container",
},
destination: {
component: "CosmosDBSql",
databaseName: "target-db",
containerName: "target-container",
},
},
},
},
];
],
};
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
@@ -201,36 +201,38 @@ describe("CopyJobActions", () => {
});
it("should filter jobs by CosmosDBSql component", async () => {
const mockResponse = [
{
properties: {
jobName: "sql-job",
status: "Completed",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 100,
totalCount: 100,
mode: "offline",
duration: "02:00:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
const mockResponse = {
value: [
{
properties: {
jobName: "sql-job",
status: "Completed",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 100,
totalCount: 100,
mode: "offline",
duration: "02:00:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
},
},
},
{
properties: {
jobName: "other-job",
status: "Completed",
lastUpdatedUtcTime: "2025-01-01T11:00:00Z",
processedCount: 100,
totalCount: 100,
mode: "offline",
duration: "01:00:00",
source: { component: "OtherComponent", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
{
properties: {
jobName: "other-job",
status: "Completed",
lastUpdatedUtcTime: "2025-01-01T11:00:00Z",
processedCount: 100,
totalCount: 100,
mode: "offline",
duration: "01:00:00",
source: { component: "OtherComponent", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
},
},
},
];
],
};
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
@@ -245,36 +247,38 @@ describe("CopyJobActions", () => {
});
it("should sort jobs by last updated time (newest first)", async () => {
const mockResponse = [
{
properties: {
jobName: "older-job",
status: "Completed",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 100,
totalCount: 100,
mode: "offline",
duration: "01:00:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
const mockResponse = {
value: [
{
properties: {
jobName: "older-job",
status: "Completed",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 100,
totalCount: 100,
mode: "offline",
duration: "01:00:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
},
},
},
{
properties: {
jobName: "newer-job",
status: "InProgress",
lastUpdatedUtcTime: "2025-01-02T10:00:00Z",
processedCount: 50,
totalCount: 100,
mode: "online",
duration: "00:30:00",
source: { component: "CosmosDBSql", databaseName: "db3", containerName: "c3" },
destination: { component: "CosmosDBSql", databaseName: "db4", containerName: "c4" },
{
properties: {
jobName: "newer-job",
status: "InProgress",
lastUpdatedUtcTime: "2025-01-02T10:00:00Z",
processedCount: 50,
totalCount: 100,
mode: "online",
duration: "00:30:00",
source: { component: "CosmosDBSql", databaseName: "db3", containerName: "c3" },
destination: { component: "CosmosDBSql", databaseName: "db4", containerName: "c4" },
},
},
},
];
],
};
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
@@ -289,23 +293,25 @@ describe("CopyJobActions", () => {
});
it("should calculate completion percentage correctly", async () => {
const mockResponse = [
{
properties: {
jobName: "job-1",
status: "InProgress",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 75,
totalCount: 100,
mode: "online",
duration: "01:00:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
const mockResponse = {
value: [
{
properties: {
jobName: "job-1",
status: "InProgress",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 75,
totalCount: 100,
mode: "online",
duration: "01:00:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
},
},
},
];
],
};
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
@@ -319,23 +325,25 @@ describe("CopyJobActions", () => {
});
it("should handle zero total count gracefully", async () => {
const mockResponse = [
{
properties: {
jobName: "job-1",
status: "Pending",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 0,
totalCount: 0,
mode: "online",
duration: "00:00:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
const mockResponse = {
value: [
{
properties: {
jobName: "job-1",
status: "Pending",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 0,
totalCount: 0,
mode: "online",
duration: "00:00:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
},
},
},
];
],
};
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
@@ -353,24 +361,26 @@ describe("CopyJobActions", () => {
message: "Error message line 1\r\n\r\nError message line 2",
code: "ErrorCode123",
};
const mockResponse = [
{
properties: {
jobName: "failed-job",
status: "Failed",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 50,
totalCount: 100,
mode: "offline",
duration: "00:30:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
error: mockError,
const mockResponse = {
value: [
{
properties: {
jobName: "failed-job",
status: "Failed",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 50,
totalCount: 100,
mode: "offline",
duration: "00:30:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
error: mockError,
},
},
},
];
],
};
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
@@ -398,7 +408,7 @@ describe("CopyJobActions", () => {
};
(global as any).AbortController = jest.fn(() => mockAbortController);
(getDataTransferJobs as jest.Mock).mockResolvedValue([]);
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue({ value: [] });
getCopyJobs();
expect(mockAbortController.abort).not.toHaveBeenCalled();
@@ -408,7 +418,9 @@ describe("CopyJobActions", () => {
});
it("should throw error for invalid response format", async () => {
(getDataTransferJobs as jest.Mock).mockResolvedValue("not-an-array");
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue({
value: "not-an-array",
});
await expect(getCopyJobs()).rejects.toThrow("Invalid migration job status response: Expected an array of jobs.");
});
@@ -418,7 +430,7 @@ describe("CopyJobActions", () => {
message: "Aborted",
content: JSON.stringify({ message: "signal is aborted without reason" }),
};
(getDataTransferJobs as jest.Mock).mockRejectedValue(abortError);
(dataTransferService.listByDatabaseAccount as jest.Mock).mockRejectedValue(abortError);
await expect(getCopyJobs()).rejects.toMatchObject({
message: expect.stringContaining("Previous copy job request was cancelled."),
@@ -427,7 +439,7 @@ describe("CopyJobActions", () => {
it("should handle generic errors", async () => {
const genericError = new Error("Network error");
(getDataTransferJobs as jest.Mock).mockRejectedValue(genericError);
(dataTransferService.listByDatabaseAccount as jest.Mock).mockRejectedValue(genericError);
await expect(getCopyJobs()).rejects.toThrow("Network error");
});

View File

@@ -1,13 +1,13 @@
import Explorer from "Explorer/Explorer";
import React from "react";
import { userContext } from "UserContext";
import { getDataTransferJobs } from "../../../Common/dataAccess/dataTransfers";
import { logError } from "../../../Common/Logger";
import { useSidePanel } from "../../../hooks/useSidePanel";
import {
cancel,
complete,
create,
listByDatabaseAccount,
pause,
resume,
} from "../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs";
@@ -63,8 +63,14 @@ export const getCopyJobs = async (): Promise<CopyJobType[]> => {
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
userContext.databaseAccount?.id || "",
);
const jobs = await getDataTransferJobs(subscriptionId, resourceGroup, accountName, copyJobsAbortController.signal);
const response = await listByDatabaseAccount(
subscriptionId,
resourceGroup,
accountName,
copyJobsAbortController.signal,
);
const jobs = response.value || [];
if (!Array.isArray(jobs)) {
throw new Error("Invalid migration job status response: Expected an array of jobs.");
}

View File

@@ -39,7 +39,7 @@ describe("CopyJobCommandBar", () => {
render(<CopyJobCommandBar explorer={mockExplorer} />);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer, false);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer);
expect(mockGetCommandBarButtons).toHaveBeenCalledTimes(1);
});
@@ -163,7 +163,7 @@ describe("CopyJobCommandBar", () => {
render(<CopyJobCommandBar explorer={mockExplorer} />);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer, false);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer);
expect(mockConvertButton.mock.calls[0][0]).toEqual(mockCommandButtonProps);
});
@@ -175,11 +175,11 @@ describe("CopyJobCommandBar", () => {
mockConvertButton.mockReturnValue([]);
const { rerender } = render(<CopyJobCommandBar explorer={mockExplorer1} />);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer1, false);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer1);
rerender(<CopyJobCommandBar explorer={mockExplorer2} />);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer2, false);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer2);
expect(mockGetCommandBarButtons).toHaveBeenCalledTimes(2);
});
});

View File

@@ -1,28 +1,24 @@
import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react";
import React from "react";
import { useThemeStore } from "../../../hooks/useTheme";
import { StyleConstants } from "../../../Common/StyleConstants";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import * as CommandBarUtil from "../../Menus/CommandBar/CommandBarUtil";
import { getThemeTokens } from "../../Theme/ThemeUtil";
import { ContainerCopyProps } from "../Types/CopyJobTypes";
import { getCommandBarButtons } from "./Utils";
const backgroundColor = StyleConstants.BaseLight;
const rootStyle = {
root: {
backgroundColor: backgroundColor,
},
};
const CopyJobCommandBar: React.FC<ContainerCopyProps> = ({ explorer }) => {
const isDarkMode = useThemeStore((state) => state.isDarkMode);
const themeTokens = getThemeTokens(isDarkMode);
const backgroundColor = themeTokens.colorNeutralBackground1;
const rootStyle = {
root: {
backgroundColor: backgroundColor,
},
};
const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(explorer, isDarkMode);
const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(explorer);
const controlButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(commandBarItems, backgroundColor);
return (
<div className="commandBarContainer" style={{ backgroundColor }}>
<div className="commandBarContainer">
<FluentCommandBar
ariaLabel="Use left and right arrow keys to navigate between commands"
styles={rootStyle}

View File

@@ -50,7 +50,7 @@ describe("CommandBar Utils", () => {
describe("getCommandBarButtons", () => {
it("should return an array of command button props", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
expect(buttons).toBeDefined();
expect(Array.isArray(buttons)).toBe(true);
@@ -58,7 +58,7 @@ describe("CommandBar Utils", () => {
});
it("should include create copy job button", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
const createButton = buttons[0];
expect(createButton).toBeDefined();
@@ -70,7 +70,7 @@ describe("CommandBar Utils", () => {
});
it("should include refresh button", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
const refreshButton = buttons[1];
expect(refreshButton).toBeDefined();
@@ -80,11 +80,11 @@ describe("CommandBar Utils", () => {
});
it("should include feedback button when platform is Portal", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
expect(buttons.length).toBe(4);
expect(buttons.length).toBe(3);
const feedbackButton = buttons[3];
const feedbackButton = buttons[2];
expect(feedbackButton).toBeDefined();
expect(feedbackButton.ariaLabel).toBe("Provide feedback on copy jobs");
expect(feedbackButton.tooltipText).toBe("Feedback");
@@ -105,13 +105,13 @@ describe("CommandBar Utils", () => {
}));
const { getCommandBarButtons: getCommandBarButtonsEmulator } = await import("./Utils");
const buttons = getCommandBarButtonsEmulator(mockExplorer, false);
const buttons = getCommandBarButtonsEmulator(mockExplorer);
expect(buttons.length).toBe(3);
expect(buttons.length).toBe(2);
});
it("should call openCreateCopyJobPanel when create button is clicked", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
const createButton = buttons[0];
createButton.onCommandClick({} as React.SyntheticEvent);
@@ -121,7 +121,7 @@ describe("CommandBar Utils", () => {
});
it("should call refreshJobList when refresh button is clicked", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
const refreshButton = buttons[1];
refreshButton.onCommandClick({} as React.SyntheticEvent);
@@ -130,8 +130,8 @@ describe("CommandBar Utils", () => {
});
it("should call openContainerCopyFeedbackBlade when feedback button is clicked", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const feedbackButton = buttons[3];
const buttons = getCommandBarButtons(mockExplorer);
const feedbackButton = buttons[2];
feedbackButton.onCommandClick({} as React.SyntheticEvent);
@@ -139,7 +139,7 @@ describe("CommandBar Utils", () => {
});
it("should return buttons with correct icon sources", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
expect(buttons[0].iconSrc).toBeDefined();
expect(buttons[0].iconAlt).toBe("Create Copy Job");
@@ -148,10 +148,7 @@ describe("CommandBar Utils", () => {
expect(buttons[1].iconAlt).toBe("Refresh");
expect(buttons[2].iconSrc).toBeDefined();
expect(buttons[2].iconAlt).toBe("Dark Theme");
expect(buttons[3].iconSrc).toBeDefined();
expect(buttons[3].iconAlt).toBe("Feedback");
expect(buttons[2].iconAlt).toBe("Feedback");
});
it("should handle null MonitorCopyJobsRefState ref gracefully", () => {
@@ -160,14 +157,14 @@ describe("CommandBar Utils", () => {
return selector(state);
});
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
const refreshButton = buttons[1];
expect(() => refreshButton.onCommandClick({} as React.SyntheticEvent)).not.toThrow();
});
it("should set hasPopup to false for all buttons", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button) => {
expect(button.hasPopup).toBe(false);
@@ -175,7 +172,7 @@ describe("CommandBar Utils", () => {
});
it("should set commandButtonLabel to undefined for all buttons", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button) => {
expect(button.commandButtonLabel).toBeUndefined();
@@ -183,7 +180,7 @@ describe("CommandBar Utils", () => {
});
it("should respect disabled state when provided", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button) => {
expect(button.disabled).toBe(false);
@@ -191,7 +188,7 @@ describe("CommandBar Utils", () => {
});
it("should return CommandButtonComponentProps with all required properties", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button: CommandButtonComponentProps) => {
expect(button).toHaveProperty("iconSrc");
@@ -205,19 +202,18 @@ describe("CommandBar Utils", () => {
});
});
it("should maintain button order: create, refresh, themeToggle, feedback", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
it("should maintain button order: create, refresh, feedback", () => {
const buttons = getCommandBarButtons(mockExplorer);
expect(buttons[0].tooltipText).toBe("Create Copy Job");
expect(buttons[1].tooltipText).toBe("Refresh");
expect(buttons[2].tooltipText).toBe("Dark Theme");
expect(buttons[3].tooltipText).toBe("Feedback");
expect(buttons[2].tooltipText).toBe("Feedback");
});
});
describe("Button click handlers", () => {
it("should execute click handlers without errors", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button) => {
expect(() => button.onCommandClick({} as React.SyntheticEvent)).not.toThrow();
@@ -225,7 +221,7 @@ describe("CommandBar Utils", () => {
});
it("should call correct action for each button", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
buttons[0].onCommandClick({} as React.SyntheticEvent);
expect(Actions.openCreateCopyJobPanel).toHaveBeenCalledWith(mockExplorer);
@@ -233,14 +229,14 @@ describe("CommandBar Utils", () => {
buttons[1].onCommandClick({} as React.SyntheticEvent);
expect(mockRefreshJobList).toHaveBeenCalled();
buttons[3].onCommandClick({} as React.SyntheticEvent);
buttons[2].onCommandClick({} as React.SyntheticEvent);
expect(mockOpenContainerCopyFeedbackBlade).toHaveBeenCalled();
});
});
describe("Accessibility", () => {
it("should have aria labels for all buttons", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button) => {
expect(button.ariaLabel).toBeDefined();
@@ -250,7 +246,7 @@ describe("CommandBar Utils", () => {
});
it("should have tooltip text for all buttons", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button) => {
expect(button.tooltipText).toBeDefined();
@@ -260,7 +256,7 @@ describe("CommandBar Utils", () => {
});
it("should have icon alt text for all buttons", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button) => {
expect(button.iconAlt).toBeDefined();

View File

@@ -1,10 +1,7 @@
import AddIcon from "../../../../images/Add.svg";
import FeedbackIcon from "../../../../images/Feedback-Command.svg";
import MoonIcon from "../../../../images/MoonIcon.svg";
import RefreshIcon from "../../../../images/refresh-cosmos.svg";
import SunIcon from "../../../../images/SunIcon.svg";
import { configContext, Platform } from "../../../ConfigContext";
import { useThemeStore } from "../../../hooks/useTheme";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import Explorer from "../../Explorer";
import * as Actions from "../Actions/CopyJobActions";
@@ -12,7 +9,7 @@ import ContainerCopyMessages from "../ContainerCopyMessages";
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
import { CopyJobCommandBarBtnType } from "../Types/CopyJobTypes";
function getCopyJobBtns(explorer: Explorer, isDarkMode: boolean): CopyJobCommandBarBtnType[] {
function getCopyJobBtns(explorer: Explorer): CopyJobCommandBarBtnType[] {
const monitorCopyJobsRef = MonitorCopyJobsRefState((state) => state.ref);
const buttons: CopyJobCommandBarBtnType[] = [
{
@@ -29,15 +26,7 @@ function getCopyJobBtns(explorer: Explorer, isDarkMode: boolean): CopyJobCommand
ariaLabel: ContainerCopyMessages.refreshButtonAriaLabel,
onClick: () => monitorCopyJobsRef?.refreshJobList(),
},
{
key: "themeToggle",
iconSrc: isDarkMode ? SunIcon : MoonIcon,
label: isDarkMode ? "Light Theme" : "Dark Theme",
ariaLabel: isDarkMode ? "Switch to Light Theme" : "Switch to Dark Theme",
onClick: () => useThemeStore.getState().toggleTheme(),
},
];
if (configContext.platform === Platform.Portal) {
buttons.push({
key: "feedback",
@@ -65,6 +54,6 @@ function btnMapper(config: CopyJobCommandBarBtnType): CommandButtonComponentProp
};
}
export function getCommandBarButtons(explorer: Explorer, isDarkMode: boolean): CommandButtonComponentProps[] {
return getCopyJobBtns(explorer, isDarkMode).map(btnMapper);
export function getCommandBarButtons(explorer: Explorer): CommandButtonComponentProps[] {
return getCopyJobBtns(explorer).map(btnMapper);
}

View File

@@ -25,18 +25,7 @@ export default {
subscriptionDropdownPlaceholder: "Select a subscription",
sourceAccountDropdownLabel: "Account",
sourceAccountDropdownPlaceholder: "Select an account",
migrationTypeOptions: {
offline: {
title: "Offline mode",
description:
"Offline container copy jobs let you copy data from a source container to a destination Cosmos DB container for supported APIs. To ensure data integrity between the source and destination, we recommend stopping updates on the source container before creating the copy job. Learn more about [offline copy jobs](https://learn.microsoft.com/azure/cosmos-db/how-to-container-copy?tabs=offline-copy&pivots=api-nosql).",
},
online: {
title: "Online mode",
description:
"Online container copy jobs let you copy data from a source container to a destination Cosmos DB NoSQL API container using the [All Versions and Delete](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview) change feed. This allows updates to continue on the source while data is copied. A brief downtime is required at the end to safely switch over client applications to the destination container. Learn more about [online copy jobs](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started).",
},
},
migrationTypeCheckboxLabel: "Copy container in offline mode",
// Select Source and Target Containers Screen
selectSourceAndTargetContainersDescription:

View File

@@ -12,12 +12,7 @@ import useToggle from "./hooks/useToggle";
const managedIdentityTooltip = (
<Text>
{ContainerCopyMessages.addManagedIdentity.tooltip.content} &nbsp;
<Link
style={{ color: "var(--colorBrandForeground1)" }}
href={ContainerCopyMessages.addManagedIdentity.tooltip.href}
target="_blank"
rel="noopener noreferrer"
>
<Link href={ContainerCopyMessages.addManagedIdentity.tooltip.href} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.addManagedIdentity.tooltip.hrefText}
</Link>
</Text>
@@ -31,7 +26,7 @@ const AddManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
return (
<Stack className="addManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<Text className="themeText">
<Text>
{ContainerCopyMessages.addManagedIdentity.description}&ensp;
<Link href={ContainerCopyMessages.addManagedIdentity.descriptionHref} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.addManagedIdentity.descriptionHrefText}

View File

@@ -13,12 +13,7 @@ import useToggle from "./hooks/useToggle";
const TooltipContent = (
<Text>
{ContainerCopyMessages.readPermissionAssigned.tooltip.content} &nbsp;
<Link
style={{ color: "var(--colorBrandForeground1)" }}
href={ContainerCopyMessages.readPermissionAssigned.tooltip.href}
target="_blank"
rel="noopener noreferrer"
>
<Link href={ContainerCopyMessages.readPermissionAssigned.tooltip.href} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.readPermissionAssigned.tooltip.hrefText}
</Link>
</Text>

View File

@@ -48,8 +48,8 @@ const PermissionGroup: React.FC<PermissionGroupConfig> = ({ id, title, descripti
tokens={{ childrenGap: 15 }}
styles={{
root: {
background: "var(--colorNeutralBackground2)",
border: "1px solid var(--colorNeutralStroke1)",
background: "#fafafa",
border: "1px solid #e1e1e1",
borderRadius: 8,
padding: 16,
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
@@ -57,11 +57,11 @@ const PermissionGroup: React.FC<PermissionGroupConfig> = ({ id, title, descripti
}}
>
<Stack tokens={{ childrenGap: 5 }}>
<Text variant="medium" style={{ fontWeight: 600, color: "var(--colorNeutralForeground1)" }}>
<Text variant="medium" style={{ fontWeight: 600 }}>
{title}
</Text>
{description && (
<Text variant="small" styles={{ root: { color: "var(--colorNeutralForeground2)" } }}>
<Text variant="small" styles={{ root: { color: "#605E5C" } }}>
{description}
</Text>
)}
@@ -105,7 +105,7 @@ const AssignPermissions = () => {
className="assignPermissionsContainer"
tokens={{ childrenGap: 20 }}
>
<Text variant="medium" style={{ color: "var(--colorNeutralForeground1)" }}>
<Text variant="medium">
{isSameAccount && copyJobState.migrationType === CopyJobMigrationType.Online
? ContainerCopyMessages.assignPermissions.intraAccountOnlineDescription(
copyJobState?.source?.account?.name || "",

View File

@@ -12,12 +12,7 @@ import useToggle from "./hooks/useToggle";
const managedIdentityTooltip = (
<Text>
{ContainerCopyMessages.defaultManagedIdentity.tooltip.content} &nbsp;
<Link
style={{ color: "var(--colorBrandForeground1)" }}
href={ContainerCopyMessages.defaultManagedIdentity.tooltip.href}
target="_blank"
rel="noopener noreferrer"
>
<Link href={ContainerCopyMessages.defaultManagedIdentity.tooltip.href} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.defaultManagedIdentity.tooltip.hrefText}
</Link>
</Text>

View File

@@ -13,12 +13,7 @@ import InfoTooltip from "../Components/InfoTooltip";
const tooltipContent = (
<Text>
{ContainerCopyMessages.pointInTimeRestore.tooltip.content} &nbsp;
<Link
style={{ color: "var(--colorBrandForeground1)" }}
href={ContainerCopyMessages.pointInTimeRestore.tooltip.href}
target="_blank"
rel="noopener noreferrer"
>
<Link href={ContainerCopyMessages.pointInTimeRestore.tooltip.href} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.pointInTimeRestore.tooltip.hrefText}
</Link>
</Text>

View File

@@ -5,7 +5,7 @@ exports[`AddManagedIdentity Snapshot Tests renders initial state correctly 1`] =
class="ms-Stack addManagedIdentityContainer css-109"
>
<span
class="themeText css-110"
class="css-110"
>
A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you dont have to store any credentials in code.
@@ -93,7 +93,7 @@ exports[`AddManagedIdentity Snapshot Tests renders loading state 1`] = `
class="ms-Stack addManagedIdentityContainer css-109"
>
<span
class="themeText css-110"
class="css-110"
>
A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you dont have to store any credentials in code.
@@ -196,13 +196,13 @@ exports[`AddManagedIdentity Snapshot Tests renders loading state 1`] = `
</div>
</div>
<span
class="themeText css-124"
class="css-124"
style="font-weight: 600;"
>
Enable system assigned managed identity
</span>
<span
class="themeText css-110"
class="css-110"
>
Enable system-assigned managed identity on the test-target-account. To confirm, click the "Yes" button.
</span>
@@ -265,7 +265,7 @@ exports[`AddManagedIdentity Snapshot Tests renders with toggle on and popover vi
class="ms-Stack addManagedIdentityContainer css-109"
>
<span
class="themeText css-110"
class="css-110"
>
A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you dont have to store any credentials in code.
@@ -351,13 +351,13 @@ exports[`AddManagedIdentity Snapshot Tests renders with toggle on and popover vi
style="max-width: 450px;"
>
<span
class="themeText css-124"
class="css-124"
style="font-weight: 600;"
>
Enable system assigned managed identity
</span>
<span
class="themeText css-110"
class="css-110"
>
Enable system-assigned managed identity on the test-target-account. To confirm, click the "Yes" button.
</span>

View File

@@ -23,10 +23,10 @@ const PopoverContainer: React.FC<PopoverContainerProps> = React.memo(
style={{ maxWidth: 450 }}
>
<LoadingOverlay isLoading={isLoading} label={ContainerCopyMessages.popoverOverlaySpinnerLabel} />
<Text variant="mediumPlus" className="themeText" style={{ fontWeight: 600 }}>
<Text variant="mediumPlus" style={{ fontWeight: 600 }}>
{title}
</Text>
<Text className="themeText">{children}</Text>
<Text>{children}</Text>
<Stack horizontal tokens={{ childrenGap: 20 }}>
<PrimaryButton text={"Yes"} onClick={onPrimary} disabled={isLoading} />
<DefaultButton text="No" onClick={onCancel} disabled={isLoading} />

View File

@@ -8,11 +8,11 @@ exports[`PopoverMessage Component Edge Cases should handle empty string title 1`
style="max-width: 450px;"
>
<span
class="themeText css-110"
class="css-110"
style="font-weight: 600;"
/>
<span
class="themeText css-111"
class="css-111"
>
<div>
Test content
@@ -76,7 +76,7 @@ exports[`PopoverMessage Component Edge Cases should handle null children 1`] = `
style="max-width: 450px;"
>
<span
class="themeText css-110"
class="css-110"
style="font-weight: 600;"
>
Test Title
@@ -139,7 +139,7 @@ exports[`PopoverMessage Component Edge Cases should handle undefined children 1`
style="max-width: 450px;"
>
<span
class="themeText css-110"
class="css-110"
style="font-weight: 600;"
>
Test Title
@@ -202,13 +202,13 @@ exports[`PopoverMessage Component Edge Cases should handle very long title 1`] =
style="max-width: 450px;"
>
<span
class="themeText css-110"
class="css-110"
style="font-weight: 600;"
>
This is a very long title that might cause layout issues or text wrapping in the popover component
</span>
<span
class="themeText css-111"
class="css-111"
>
<div>
Test content
@@ -274,13 +274,13 @@ exports[`PopoverMessage Component Rendering should render correctly when visible
style="max-width: 450px;"
>
<span
class="themeText css-110"
class="css-110"
style="font-weight: 600;"
>
Test Title
</span>
<span
class="themeText css-111"
class="css-111"
>
<div>
Test content
@@ -344,13 +344,13 @@ exports[`PopoverMessage Component Rendering should render correctly with differe
style="max-width: 450px;"
>
<span
class="themeText css-110"
class="css-110"
style="font-weight: 600;"
>
Test Title
</span>
<span
class="themeText css-111"
class="css-111"
>
<div>
<p>
@@ -419,13 +419,13 @@ exports[`PopoverMessage Component Rendering should render correctly with differe
style="max-width: 450px;"
>
<span
class="themeText css-110"
class="css-110"
style="font-weight: 600;"
>
Custom Title
</span>
<span
class="themeText css-111"
class="css-111"
>
<div>
Test content
@@ -493,13 +493,13 @@ exports[`PopoverMessage Component Rendering should render correctly with loading
data-testid="loading-overlay"
/>
<span
class="themeText css-110"
class="css-110"
style="font-weight: 600;"
>
Test Title
</span>
<span
class="themeText css-111"
class="css-111"
>
<div>
Test content

View File

@@ -41,7 +41,7 @@ const AddCollectionPanelWrapper: React.FunctionComponent<AddCollectionPanelWrapp
return (
<Stack className="addCollectionPanelWrapper">
<Stack.Item className="addCollectionPanelHeader">
<Text className="themeText">{ContainerCopyMessages.createNewContainerSubHeading}</Text>
<Text>{ContainerCopyMessages.createNewContainerSubHeading}</Text>
</Stack.Item>
<Stack.Item className="addCollectionPanelBody">
<AddCollectionPanel explorer={explorer} isCopyJobFlow={true} onSubmitSuccess={handleAddCollectionSuccess} />

View File

@@ -9,7 +9,7 @@ exports[`AddCollectionPanelWrapper Component Rendering should match snapshot 1`]
class="ms-StackItem addCollectionPanelHeader css-110"
>
<span
class="themeText css-111"
class="css-111"
>
Select the properties for your container.
</span>
@@ -50,7 +50,7 @@ exports[`AddCollectionPanelWrapper Component Rendering should match snapshot wit
class="ms-StackItem addCollectionPanelHeader css-110"
>
<span
class="themeText css-111"
class="css-111"
>
Select the properties for your container.
</span>
@@ -91,7 +91,7 @@ exports[`AddCollectionPanelWrapper Component Rendering should match snapshot wit
class="ms-StackItem addCollectionPanelHeader css-110"
>
<span
class="themeText css-111"
class="css-111"
>
Select the properties for your container.
</span>
@@ -132,7 +132,7 @@ exports[`AddCollectionPanelWrapper Component Rendering should match snapshot wit
class="ms-StackItem addCollectionPanelHeader css-110"
>
<span
class="themeText css-111"
class="css-111"
>
Select the properties for your container.
</span>

View File

@@ -36,16 +36,12 @@ const PreviewCopyJob: React.FC = () => {
<TextField data-test="job-name-textfield" value={jobName} onChange={onJobNameChange} />
</FieldRow>
<Stack>
<Text className="bold themeText">{ContainerCopyMessages.sourceSubscriptionLabel}</Text>
<Text data-test="source-subscription-name" className="themeText">
{copyJobState.source?.subscription?.displayName}
</Text>
<Text className="bold">{ContainerCopyMessages.sourceSubscriptionLabel}</Text>
<Text data-test="source-subscription-name">{copyJobState.source?.subscription?.displayName}</Text>
</Stack>
<Stack>
<Text className="bold themeText">{ContainerCopyMessages.sourceAccountLabel}</Text>
<Text data-test="source-account-name" className="themeText">
{copyJobState.source?.account?.name}
</Text>
<Text className="bold">{ContainerCopyMessages.sourceAccountLabel}</Text>
<Text data-test="source-account-name">{copyJobState.source?.account?.name}</Text>
</Stack>
<Stack>
<DetailsList

View File

@@ -47,12 +47,12 @@ exports[`PreviewCopyJob should handle special characters in database and contain
class="ms-Stack css-124"
>
<span
class="bold themeText css-125"
class="bold css-125"
>
Source subscription
</span>
<span
class="themeText css-125"
class="css-125"
data-test="source-subscription-name"
>
Test Subscription
@@ -62,12 +62,12 @@ exports[`PreviewCopyJob should handle special characters in database and contain
class="ms-Stack css-124"
>
<span
class="bold themeText css-125"
class="bold css-125"
>
Source account
</span>
<span
class="themeText css-125"
class="css-125"
data-test="source-account-name"
>
test-account
@@ -369,12 +369,12 @@ exports[`PreviewCopyJob should render component with cross-subscription setup 1`
class="ms-Stack css-124"
>
<span
class="bold themeText css-125"
class="bold css-125"
>
Source subscription
</span>
<span
class="themeText css-125"
class="css-125"
data-test="source-subscription-name"
>
Test Subscription
@@ -384,12 +384,12 @@ exports[`PreviewCopyJob should render component with cross-subscription setup 1`
class="ms-Stack css-124"
>
<span
class="bold themeText css-125"
class="bold css-125"
>
Source account
</span>
<span
class="themeText css-125"
class="css-125"
data-test="source-account-name"
>
test-account
@@ -691,12 +691,12 @@ exports[`PreviewCopyJob should render with default state and empty job name 1`]
class="ms-Stack css-124"
>
<span
class="bold themeText css-125"
class="bold css-125"
>
Source subscription
</span>
<span
class="themeText css-125"
class="css-125"
data-test="source-subscription-name"
>
Test Subscription
@@ -706,12 +706,12 @@ exports[`PreviewCopyJob should render with default state and empty job name 1`]
class="ms-Stack css-124"
>
<span
class="bold themeText css-125"
class="bold css-125"
>
Source account
</span>
<span
class="themeText css-125"
class="css-125"
data-test="source-account-name"
>
test-account
@@ -1013,12 +1013,12 @@ exports[`PreviewCopyJob should render with long subscription and account names 1
class="ms-Stack css-124"
>
<span
class="bold themeText css-125"
class="bold css-125"
>
Source subscription
</span>
<span
class="themeText css-125"
class="css-125"
data-test="source-subscription-name"
>
This is a very long subscription name that might cause display issues if not handled properly
@@ -1028,12 +1028,12 @@ exports[`PreviewCopyJob should render with long subscription and account names 1
class="ms-Stack css-124"
>
<span
class="bold themeText css-125"
class="bold css-125"
>
Source account
</span>
<span
class="themeText css-125"
class="css-125"
data-test="source-account-name"
>
this-is-a-very-long-database-account-name-that-might-cause-display-issues
@@ -1335,12 +1335,12 @@ exports[`PreviewCopyJob should render with missing source account information 1`
class="ms-Stack css-124"
>
<span
class="bold themeText css-125"
class="bold css-125"
>
Source subscription
</span>
<span
class="themeText css-125"
class="css-125"
data-test="source-subscription-name"
>
Test Subscription
@@ -1350,7 +1350,7 @@ exports[`PreviewCopyJob should render with missing source account information 1`
class="ms-Stack css-124"
>
<span
class="bold themeText css-125"
class="bold css-125"
>
Source account
</span>
@@ -1651,7 +1651,7 @@ exports[`PreviewCopyJob should render with missing source subscription informati
class="ms-Stack css-124"
>
<span
class="bold themeText css-125"
class="bold css-125"
>
Source subscription
</span>
@@ -1660,12 +1660,12 @@ exports[`PreviewCopyJob should render with missing source subscription informati
class="ms-Stack css-124"
>
<span
class="bold themeText css-125"
class="bold css-125"
>
Source account
</span>
<span
class="themeText css-125"
class="css-125"
data-test="source-account-name"
>
test-account
@@ -1967,12 +1967,12 @@ exports[`PreviewCopyJob should render with online migration type 1`] = `
class="ms-Stack css-124"
>
<span
class="bold themeText css-125"
class="bold css-125"
>
Source subscription
</span>
<span
class="themeText css-125"
class="css-125"
data-test="source-subscription-name"
>
Test Subscription
@@ -1982,12 +1982,12 @@ exports[`PreviewCopyJob should render with online migration type 1`] = `
class="ms-Stack css-124"
>
<span
class="bold themeText css-125"
class="bold css-125"
>
Source account
</span>
<span
class="themeText css-125"
class="css-125"
data-test="source-account-name"
>
test-account
@@ -2289,12 +2289,12 @@ exports[`PreviewCopyJob should render with pre-filled job name 1`] = `
class="ms-Stack css-124"
>
<span
class="bold themeText css-125"
class="bold css-125"
>
Source subscription
</span>
<span
class="themeText css-125"
class="css-125"
data-test="source-subscription-name"
>
Test Subscription
@@ -2304,12 +2304,12 @@ exports[`PreviewCopyJob should render with pre-filled job name 1`] = `
class="ms-Stack css-124"
>
<span
class="bold themeText css-125"
class="bold css-125"
>
Source account
</span>
<span
class="themeText css-125"
class="css-125"
data-test="source-account-name"
>
test-account
@@ -2611,12 +2611,12 @@ exports[`PreviewCopyJob should render with undefined database and container name
class="ms-Stack css-124"
>
<span
class="bold themeText css-125"
class="bold css-125"
>
Source subscription
</span>
<span
class="themeText css-125"
class="css-125"
data-test="source-subscription-name"
>
Test Subscription
@@ -2626,12 +2626,12 @@ exports[`PreviewCopyJob should render with undefined database and container name
class="ms-Stack css-124"
>
<span
class="bold themeText css-125"
class="bold css-125"
>
Source account
</span>
<span
class="themeText css-125"
class="css-125"
data-test="source-account-name"
>
test-account

View File

@@ -1,241 +0,0 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../../Context/CopyJobContext";
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
import { MigrationType } from "./MigrationType";
jest.mock("../../../../Context/CopyJobContext", () => ({
useCopyJobContext: jest.fn(),
}));
describe("MigrationType", () => {
const mockSetCopyJobState = jest.fn();
const defaultContextValue = {
copyJobState: {
jobName: "",
migrationType: CopyJobMigrationType.Online,
source: {
subscription: null as any,
account: null as any,
databaseId: "",
containerId: "",
},
target: {
subscriptionId: "",
account: null as any,
databaseId: "",
containerId: "",
},
sourceReadAccessFromTarget: false,
},
setCopyJobState: mockSetCopyJobState,
flow: { currentScreen: "selectAccount" },
setFlow: jest.fn(),
contextError: "",
setContextError: jest.fn(),
explorer: {} as any,
resetCopyJobState: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
(useCopyJobContext as jest.Mock).mockReturnValue(defaultContextValue);
});
describe("Component Rendering", () => {
it("should render migration type component with radio buttons", () => {
const { container } = render(<MigrationType />);
expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument();
expect(screen.getByRole("radiogroup")).toBeInTheDocument();
const offlineRadio = screen.getByRole("radio", {
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
});
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
expect(offlineRadio).toBeInTheDocument();
expect(onlineRadio).toBeInTheDocument();
expect(container).toMatchSnapshot();
});
it("should render with online mode selected by default", () => {
render(<MigrationType />);
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
const offlineRadio = screen.getByRole("radio", {
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
});
expect(onlineRadio).toBeChecked();
expect(offlineRadio).not.toBeChecked();
});
it("should render with offline mode selected when state is offline", () => {
(useCopyJobContext as jest.Mock).mockReturnValue({
...defaultContextValue,
copyJobState: {
...defaultContextValue.copyJobState,
migrationType: CopyJobMigrationType.Offline,
},
});
render(<MigrationType />);
const offlineRadio = screen.getByRole("radio", {
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
});
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
expect(offlineRadio).toBeChecked();
expect(onlineRadio).not.toBeChecked();
});
});
describe("Descriptions and Learn More Links", () => {
it("should render online description and learn more link when online is selected", () => {
const { container } = render(<MigrationType />);
expect(container.querySelector("[data-test='migration-type-description-online']")).toBeInTheDocument();
const learnMoreLink = screen.getByRole("link", {
name: "online copy jobs",
});
expect(learnMoreLink).toBeInTheDocument();
expect(learnMoreLink).toHaveAttribute(
"href",
"https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started",
);
expect(learnMoreLink).toHaveAttribute("target", "_blank");
});
it("should render offline description and learn more link when offline is selected", () => {
(useCopyJobContext as jest.Mock).mockReturnValue({
...defaultContextValue,
copyJobState: {
...defaultContextValue.copyJobState,
migrationType: CopyJobMigrationType.Offline,
},
});
const { container } = render(<MigrationType />);
expect(container.querySelector("[data-test='migration-type-description-offline']")).toBeInTheDocument();
const learnMoreLink = screen.getByRole("link", {
name: "offline copy jobs",
});
expect(learnMoreLink).toBeInTheDocument();
expect(learnMoreLink).toHaveAttribute(
"href",
"https://learn.microsoft.com/azure/cosmos-db/how-to-container-copy?tabs=offline-copy&pivots=api-nosql",
);
});
});
describe("User Interactions", () => {
it("should call setCopyJobState when offline radio button is clicked", () => {
render(<MigrationType />);
const offlineRadio = screen.getByRole("radio", {
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
});
fireEvent.click(offlineRadio);
expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
const updateFunction = mockSetCopyJobState.mock.calls[0][0];
const result = updateFunction(defaultContextValue.copyJobState);
expect(result).toEqual({
...defaultContextValue.copyJobState,
migrationType: CopyJobMigrationType.Offline,
});
});
it("should call setCopyJobState when online radio button is clicked", () => {
(useCopyJobContext as jest.Mock).mockReturnValue({
...defaultContextValue,
copyJobState: {
...defaultContextValue.copyJobState,
migrationType: CopyJobMigrationType.Offline,
},
});
render(<MigrationType />);
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
fireEvent.click(onlineRadio);
expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
const updateFunction = mockSetCopyJobState.mock.calls[0][0];
const result = updateFunction({
...defaultContextValue.copyJobState,
migrationType: CopyJobMigrationType.Offline,
});
expect(result).toEqual({
...defaultContextValue.copyJobState,
migrationType: CopyJobMigrationType.Online,
});
});
});
describe("Accessibility", () => {
it("should have proper ARIA attributes", () => {
render(<MigrationType />);
const choiceGroup = screen.getByRole("radiogroup");
expect(choiceGroup).toBeInTheDocument();
expect(choiceGroup).toHaveAttribute("aria-labelledby", "migrationTypeChoiceGroup");
});
it("should have proper radio button labels", () => {
render(<MigrationType />);
expect(
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.offline.title }),
).toBeInTheDocument();
expect(
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title }),
).toBeInTheDocument();
});
});
describe("Edge Cases", () => {
it("should handle undefined migration type gracefully", () => {
(useCopyJobContext as jest.Mock).mockReturnValue({
...defaultContextValue,
copyJobState: {
...defaultContextValue.copyJobState,
migrationType: undefined,
},
});
const { container } = render(<MigrationType />);
expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument();
expect(
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.offline.title }),
).toBeInTheDocument();
expect(
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title }),
).toBeInTheDocument();
});
it("should handle null copyJobState gracefully", () => {
(useCopyJobContext as jest.Mock).mockReturnValue({
...defaultContextValue,
copyJobState: null,
});
const { container } = render(<MigrationType />);
expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument();
});
});
});

View File

@@ -1,77 +0,0 @@
/* eslint-disable react/prop-types */
/* eslint-disable react/display-name */
import { ChoiceGroup, IChoiceGroupOption, Stack, Text } from "@fluentui/react";
import MarkdownRender from "@nteract/markdown";
import { useCopyJobContext } from "Explorer/ContainerCopy/Context/CopyJobContext";
import React from "react";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
interface MigrationTypeProps {}
const options: IChoiceGroupOption[] = [
{
key: CopyJobMigrationType.Offline,
text: ContainerCopyMessages.migrationTypeOptions.offline.title,
styles: { root: { width: "33%" } },
},
{
key: CopyJobMigrationType.Online,
text: ContainerCopyMessages.migrationTypeOptions.online.title,
styles: { root: { width: "33%" } },
},
];
const choiceGroupStyles = {
flexContainer: { display: "flex" as const },
root: {
selectors: {
".ms-ChoiceField": {
color: "var(--colorNeutralForeground1)",
},
".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
color: "var(--colorNeutralForeground1)",
},
},
},
};
export const MigrationType: React.FC<MigrationTypeProps> = React.memo(() => {
const { copyJobState, setCopyJobState } = useCopyJobContext();
const handleChange = (_ev?: React.FormEvent, option?: IChoiceGroupOption) => {
if (option) {
setCopyJobState((prevState) => ({
...prevState,
migrationType: option.key as CopyJobMigrationType,
}));
}
};
const selectedKey = copyJobState?.migrationType ?? "";
const selectedKeyLowercase = selectedKey.toLowerCase() as keyof typeof ContainerCopyMessages.migrationTypeOptions;
const selectedKeyContent = ContainerCopyMessages.migrationTypeOptions[selectedKeyLowercase];
return (
<Stack data-test="migration-type" className="migrationTypeContainer">
<Stack.Item>
<ChoiceGroup
selectedKey={selectedKey}
options={options}
onChange={handleChange}
ariaLabelledBy="migrationTypeChoiceGroup"
styles={choiceGroupStyles}
/>
</Stack.Item>
{selectedKeyContent && (
<Stack.Item styles={{ root: { marginTop: 10 } }}>
<Text
variant="small"
className="migrationTypeDescription"
data-test={`migration-type-description-${selectedKeyLowercase}`}
>
<MarkdownRender source={selectedKeyContent.description} linkTarget="_blank" />
</Text>
</Stack.Item>
)}
</Stack>
);
});

View File

@@ -0,0 +1,72 @@
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import React from "react";
import { MigrationTypeCheckbox } from "./MigrationTypeCheckbox";
describe("MigrationTypeCheckbox", () => {
const mockOnChange = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.clearAllMocks();
});
describe("Component Rendering", () => {
it("should render with default props (unchecked state)", () => {
const { container } = render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
expect(container.firstChild).toMatchSnapshot();
});
it("should render in checked state", () => {
const { container } = render(<MigrationTypeCheckbox checked={true} onChange={mockOnChange} />);
expect(container.firstChild).toMatchSnapshot();
});
it("should display the correct label text", () => {
render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
const checkbox = screen.getByRole("checkbox");
expect(checkbox).toBeInTheDocument();
const label = screen.getByText("Copy container in offline mode");
expect(label).toBeInTheDocument();
});
it("should have correct accessibility attributes when checked", () => {
render(<MigrationTypeCheckbox checked={true} onChange={mockOnChange} />);
const checkbox = screen.getByRole("checkbox");
expect(checkbox).toBeChecked();
expect(checkbox).toHaveAttribute("checked");
});
});
describe("FluentUI Integration", () => {
it("should render FluentUI Checkbox component correctly", () => {
render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
const checkbox = screen.getByRole("checkbox");
expect(checkbox).toBeInTheDocument();
expect(checkbox).toHaveAttribute("type", "checkbox");
});
it("should render FluentUI Stack component correctly", () => {
render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
const stackContainer = document.querySelector(".migrationTypeRow");
expect(stackContainer).toBeInTheDocument();
});
it("should apply FluentUI Stack horizontal alignment correctly", () => {
const { container } = render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
const stackContainer = container.querySelector(".migrationTypeRow");
expect(stackContainer).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,16 @@
/* eslint-disable react/prop-types */
/* eslint-disable react/display-name */
import { Checkbox, Stack } from "@fluentui/react";
import React from "react";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
interface MigrationTypeCheckboxProps {
checked: boolean;
onChange: (_ev?: React.FormEvent, checked?: boolean) => void;
}
export const MigrationTypeCheckbox: React.FC<MigrationTypeCheckboxProps> = React.memo(({ checked, onChange }) => (
<Stack horizontal horizontalAlign="space-between" className="migrationTypeRow" data-test="migration-type-checkbox">
<Checkbox label={ContainerCopyMessages.migrationTypeCheckboxLabel} checked={checked} onChange={onChange} />
</Stack>
));

View File

@@ -1,109 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MigrationType Component Rendering should render migration type component with radio buttons 1`] = `
<div>
<div
class="ms-Stack migrationTypeContainer css-109"
data-test="migration-type"
>
<div
class="ms-StackItem css-110"
>
<div
class="ms-ChoiceFieldGroup root-111"
>
<div
aria-labelledby="migrationTypeChoiceGroup"
role="radiogroup"
>
<div
class="ms-ChoiceFieldGroup-flexContainer flexContainer-112"
>
<div
class="ms-ChoiceField root-113"
>
<div
class="ms-ChoiceField-wrapper"
>
<input
class="ms-ChoiceField-input input-114"
id="ChoiceGroup0-offline"
name="ChoiceGroup0"
type="radio"
/>
<label
class="ms-ChoiceField-field field-115"
for="ChoiceGroup0-offline"
>
<span
class="ms-ChoiceFieldLabel"
id="ChoiceGroupLabel1-offline"
>
Offline mode
</span>
</label>
</div>
</div>
<div
class="ms-ChoiceField root-113"
>
<div
class="ms-ChoiceField-wrapper"
>
<input
checked=""
class="ms-ChoiceField-input input-114"
id="ChoiceGroup0-online"
name="ChoiceGroup0"
type="radio"
/>
<label
class="ms-ChoiceField-field is-checked field-120"
for="ChoiceGroup0-online"
>
<span
class="ms-ChoiceFieldLabel"
id="ChoiceGroupLabel1-online"
>
Online mode
</span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="ms-StackItem css-123"
>
<span
class="migrationTypeDescription css-124"
data-test="migration-type-description-online"
>
<div
class="markdown-body "
>
<p>
Online container copy jobs let you copy data from a source container to a destination Cosmos DB NoSQL API container using the
<a
href="https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview"
target="_blank"
>
All Versions and Delete
</a>
change feed. This allows updates to continue on the source while data is copied. A brief downtime is required at the end to safely switch over client applications to the destination container. Learn more about
<a
href="https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started"
target="_blank"
>
online copy jobs
</a>
.
</p>
</div>
</span>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,82 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MigrationTypeCheckbox Component Rendering should render in checked state 1`] = `
<div
class="ms-Stack migrationTypeRow css-109"
data-test="migration-type-checkbox"
>
<div
class="ms-Checkbox is-checked is-enabled root-119"
>
<input
checked=""
class="input-111"
data-ktp-execute-target="true"
id="checkbox-1"
type="checkbox"
/>
<label
class="ms-Checkbox-label label-112"
for="checkbox-1"
>
<div
class="ms-Checkbox-checkbox checkbox-120"
data-ktp-target="true"
>
<i
aria-hidden="true"
class="ms-Checkbox-checkmark checkmark-122"
data-icon-name="CheckMark"
>
</i>
</div>
<span
class="ms-Checkbox-text text-115"
>
Copy container in offline mode
</span>
</label>
</div>
</div>
`;
exports[`MigrationTypeCheckbox Component Rendering should render with default props (unchecked state) 1`] = `
<div
class="ms-Stack migrationTypeRow css-109"
data-test="migration-type-checkbox"
>
<div
class="ms-Checkbox is-enabled root-110"
>
<input
class="input-111"
data-ktp-execute-target="true"
id="checkbox-0"
type="checkbox"
/>
<label
class="ms-Checkbox-label label-112"
for="checkbox-0"
>
<div
class="ms-Checkbox-checkbox checkbox-113"
data-ktp-target="true"
>
<i
aria-hidden="true"
class="ms-Checkbox-checkmark checkmark-118"
data-icon-name="CheckMark"
>
</i>
</div>
<span
class="ms-Checkbox-text text-115"
>
Copy container in offline mode
</span>
</label>
</div>
</div>
`;

View File

@@ -1,5 +1,5 @@
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
@@ -18,8 +18,19 @@ jest.mock("./Components/AccountDropdown", () => ({
AccountDropdown: jest.fn(() => <div data-testid="account-dropdown">Account Dropdown</div>),
}));
jest.mock("./Components/MigrationType", () => ({
MigrationType: jest.fn(() => <div data-testid="migration-type">Migration Type</div>),
jest.mock("./Components/MigrationTypeCheckbox", () => ({
MigrationTypeCheckbox: jest.fn(({ checked, onChange }: { checked: boolean; onChange: () => void }) => (
<div data-testid="migration-type-checkbox">
<input
type="checkbox"
checked={checked}
onChange={onChange}
data-testid="migration-checkbox-input"
aria-label="Migration Type Checkbox"
/>
Copy container in offline mode
</div>
)),
}));
describe("SelectAccount", () => {
@@ -72,7 +83,7 @@ describe("SelectAccount", () => {
expect(screen.getByTestId("subscription-dropdown")).toBeInTheDocument();
expect(screen.getByTestId("account-dropdown")).toBeInTheDocument();
expect(screen.getByTestId("migration-type")).toBeInTheDocument();
expect(screen.getByTestId("migration-type-checkbox")).toBeInTheDocument();
});
it("should render correctly with snapshot", () => {
@@ -82,20 +93,78 @@ describe("SelectAccount", () => {
});
describe("Migration Type Functionality", () => {
it("should render migration type component", () => {
it("should display migration type checkbox as unchecked when migrationType is Online", () => {
(useCopyJobContext as jest.Mock).mockReturnValue({
...defaultContextValue,
copyJobState: {
...defaultContextValue.copyJobState,
migrationType: CopyJobMigrationType.Online,
},
});
render(<SelectAccount />);
const migrationTypeComponent = screen.getByTestId("migration-type");
expect(migrationTypeComponent).toBeInTheDocument();
const checkbox = screen.getByTestId("migration-checkbox-input");
expect(checkbox).not.toBeChecked();
});
it("should display migration type checkbox as checked when migrationType is Offline", () => {
(useCopyJobContext as jest.Mock).mockReturnValue({
...defaultContextValue,
copyJobState: {
...defaultContextValue.copyJobState,
migrationType: CopyJobMigrationType.Offline,
},
});
render(<SelectAccount />);
const checkbox = screen.getByTestId("migration-checkbox-input");
expect(checkbox).toBeChecked();
});
it("should call setCopyJobState with Online migration type when checkbox is unchecked", () => {
(useCopyJobContext as jest.Mock).mockReturnValue({
...defaultContextValue,
copyJobState: {
...defaultContextValue.copyJobState,
migrationType: CopyJobMigrationType.Offline,
},
});
render(<SelectAccount />);
const checkbox = screen.getByTestId("migration-checkbox-input");
fireEvent.click(checkbox);
expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
const updateFunction = mockSetCopyJobState.mock.calls[0][0];
const previousState = {
...defaultContextValue.copyJobState,
migrationType: CopyJobMigrationType.Offline,
};
const result = updateFunction(previousState);
expect(result).toEqual({
...previousState,
migrationType: CopyJobMigrationType.Online,
});
});
});
describe("Performance and Optimization", () => {
it("should render without performance issues", () => {
it("should maintain referential equality of handler functions between renders", async () => {
const { rerender } = render(<SelectAccount />);
const migrationCheckbox = (await import("./Components/MigrationTypeCheckbox")).MigrationTypeCheckbox as jest.Mock;
const firstRenderHandler = migrationCheckbox.mock.calls[migrationCheckbox.mock.calls.length - 1][0].onChange;
rerender(<SelectAccount />);
expect(screen.getByTestId("migration-type")).toBeInTheDocument();
const secondRenderHandler = migrationCheckbox.mock.calls[migrationCheckbox.mock.calls.length - 1][0].onChange;
expect(firstRenderHandler).toBe(secondRenderHandler);
});
});
});

View File

@@ -1,20 +1,33 @@
import { Stack, Text } from "@fluentui/react";
import React from "react";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
import { AccountDropdown } from "./Components/AccountDropdown";
import { MigrationType } from "./Components/MigrationType";
import { MigrationTypeCheckbox } from "./Components/MigrationTypeCheckbox";
import { SubscriptionDropdown } from "./Components/SubscriptionDropdown";
const SelectAccount = React.memo(() => {
const { copyJobState, setCopyJobState } = useCopyJobContext();
const handleMigrationTypeChange = (_ev?: React.FormEvent<HTMLElement>, checked?: boolean) => {
setCopyJobState((prevState) => ({
...prevState,
migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online,
}));
};
const migrationTypeChecked = copyJobState?.migrationType === CopyJobMigrationType.Offline;
return (
<Stack data-test="Panel:SelectAccountContainer" className="selectAccountContainer" tokens={{ childrenGap: 15 }}>
<Text className="themeText">{ContainerCopyMessages.selectAccountDescription}</Text>
<Text>{ContainerCopyMessages.selectAccountDescription}</Text>
<SubscriptionDropdown />
<AccountDropdown />
<MigrationType />
<MigrationTypeCheckbox checked={migrationTypeChecked} onChange={handleMigrationTypeChange} />
</Stack>
);
});

View File

@@ -6,7 +6,7 @@ exports[`SelectAccount Component Rendering should render correctly with snapshot
data-test="Panel:SelectAccountContainer"
>
<span
class="themeText css-110"
class="css-110"
>
Please select a source account from which to copy.
</span>
@@ -21,9 +21,14 @@ exports[`SelectAccount Component Rendering should render correctly with snapshot
Account Dropdown
</div>
<div
data-testid="migration-type"
data-testid="migration-type-checkbox"
>
Migration Type
<input
aria-label="Migration Type Checkbox"
data-testid="migration-checkbox-input"
type="checkbox"
/>
Copy container in offline mode
</div>
</div>
`;

View File

@@ -52,7 +52,7 @@ const SelectSourceAndTargetContainers = ({ showAddCollectionPanel }: SelectSourc
className="selectSourceAndTargetContainers"
tokens={{ childrenGap: 25 }}
>
<span className="themeText">{ContainerCopyMessages.selectSourceAndTargetContainersDescription}</span>
<span>{ContainerCopyMessages.selectSourceAndTargetContainersDescription}</span>
<DatabaseContainerSection
heading={ContainerCopyMessages.sourceContainerSubHeading}
databaseOptions={sourceDatabaseOptions}

View File

@@ -44,11 +44,7 @@ export const DatabaseContainerSection = ({
data-test={`${sectionType}-containerDropdown`}
/>
{handleOnDemandCreateContainer && (
<ActionButton
className="create-container-link-btn"
style={{ color: "var(--colorBrandForeground1)" }}
onClick={() => handleOnDemandCreateContainer()}
>
<ActionButton className="create-container-link-btn" onClick={() => handleOnDemandCreateContainer()}>
{ContainerCopyMessages.createContainerButtonLabel}
</ActionButton>
)}

View File

@@ -1,6 +1,5 @@
import { DetailsList, DetailsListLayoutMode, IColumn, Stack, Text } from "@fluentui/react";
import React, { memo } from "react";
import { useThemeStore } from "../../../../hooks/useTheme";
import ContainerCopyMessages from "../../ContainerCopyMessages";
import { CopyJobStatusType } from "../../Enums/CopyJobEnums";
import { CopyJobType } from "../../Types/CopyJobTypes";
@@ -64,19 +63,6 @@ const getCopyJobDetailsListColumns = (): IColumn[] => {
};
const CopyJobDetails: React.FC<CopyJobDetailsProps> = ({ job }) => {
const isDarkMode = useThemeStore((state) => state.isDarkMode);
const errorMessageStyle: React.CSSProperties = {
whiteSpace: "pre-wrap",
...(isDarkMode && {
whiteSpace: "pre-wrap",
backgroundColor: "var(--colorNeutralBackground2)",
color: "var(--colorNeutralForeground1)",
padding: "10px",
borderRadius: "4px",
}),
};
const selectedContainers = [
{
sourceContainerName: job?.Source?.containerName || "N/A",
@@ -91,10 +77,10 @@ const CopyJobDetails: React.FC<CopyJobDetailsProps> = ({ job }) => {
<Stack className="copyJobDetailsContainer" tokens={{ childrenGap: 15 }} data-testid="copy-job-details">
{job.Error ? (
<Stack.Item data-testid="error-stack" style={sectionCss.verticalAlign}>
<Text className="bold themeText" style={sectionCss.headingText}>
<Text className="bold" style={sectionCss.headingText}>
{ContainerCopyMessages.errorTitle}
</Text>
<Text as="pre" style={errorMessageStyle}>
<Text as="pre" style={{ whiteSpace: "pre-wrap" }}>
{job.Error.message}
</Text>
</Stack.Item>
@@ -102,16 +88,16 @@ const CopyJobDetails: React.FC<CopyJobDetailsProps> = ({ job }) => {
<Stack.Item data-testid="selectedcollection-stack">
<Stack tokens={{ childrenGap: 15 }}>
<Stack.Item style={sectionCss.verticalAlign}>
<Text className="bold themeText">{ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime}</Text>
<Text className="themeText">{job.LastUpdatedTime}</Text>
<Text className="bold">{ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime}</Text>
<Text>{job.LastUpdatedTime}</Text>
</Stack.Item>
<Stack.Item style={sectionCss.verticalAlign}>
<Text className="bold themeText">{ContainerCopyMessages.sourceAccountLabel}</Text>
<Text className="themeText">{job.Source?.remoteAccountName}</Text>
<Text className="bold">{ContainerCopyMessages.sourceAccountLabel}</Text>
<Text>{job.Source?.remoteAccountName}</Text>
</Stack.Item>
<Stack.Item style={sectionCss.verticalAlign}>
<Text className="bold themeText">{ContainerCopyMessages.MonitorJobs.Columns.mode}</Text>
<Text className="themeText">{job.Mode}</Text>
<Text className="bold">{ContainerCopyMessages.MonitorJobs.Columns.mode}</Text>
<Text>{job.Mode}</Text>
</Stack.Item>
</Stack>
</Stack.Item>

View File

@@ -1,14 +1,30 @@
import { FontIcon, mergeStyles, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react";
import { FontIcon, getTheme, mergeStyles, mergeStyleSets, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react";
import PropTypes from "prop-types";
import React from "react";
import ContainerCopyMessages from "../../ContainerCopyMessages";
import { CopyJobStatusType } from "../../Enums/CopyJobEnums";
const theme = getTheme();
const iconClass = mergeStyles({
fontSize: "16px",
marginRight: "8px",
});
const classNames = mergeStyleSets({
[CopyJobStatusType.Pending]: [{ color: theme.semanticColors.bodySubtext }, iconClass],
[CopyJobStatusType.InProgress]: [{ color: theme.palette.themePrimary }, iconClass],
[CopyJobStatusType.Running]: [{ color: theme.palette.themePrimary }, iconClass],
[CopyJobStatusType.Partitioning]: [{ color: theme.palette.themePrimary }, iconClass],
[CopyJobStatusType.Paused]: [{ color: theme.palette.themePrimary }, iconClass],
[CopyJobStatusType.Skipped]: [{ color: theme.semanticColors.bodySubtext }, iconClass],
[CopyJobStatusType.Cancelled]: [{ color: theme.semanticColors.bodySubtext }, iconClass],
[CopyJobStatusType.Failed]: [{ color: theme.semanticColors.errorIcon }, iconClass],
[CopyJobStatusType.Faulted]: [{ color: theme.semanticColors.errorIcon }, iconClass],
[CopyJobStatusType.Completed]: [{ color: theme.semanticColors.successIcon }, iconClass],
unknown: [{ color: theme.semanticColors.bodySubtext }, iconClass],
});
const iconMap: Partial<Record<CopyJobStatusType, string>> = {
[CopyJobStatusType.Pending]: "Clock",
[CopyJobStatusType.Paused]: "CirclePause",
@@ -19,17 +35,6 @@ const iconMap: Partial<Record<CopyJobStatusType, string>> = {
[CopyJobStatusType.Completed]: "CompletedSolid",
};
// Icon colors for different statuses
const statusIconColors: Partial<Record<CopyJobStatusType, string>> = {
[CopyJobStatusType.Failed]: "var(--colorPaletteRedForeground1)",
[CopyJobStatusType.Faulted]: "var(--colorPaletteRedForeground1)",
[CopyJobStatusType.Completed]: "var(--colorSuccessGreen)",
[CopyJobStatusType.InProgress]: "var(--colorBrandForeground1)",
[CopyJobStatusType.Running]: "var(--colorBrandForeground1)",
[CopyJobStatusType.Partitioning]: "var(--colorBrandForeground1)",
[CopyJobStatusType.Paused]: "var(--colorBrandForeground1)",
};
export interface CopyJobStatusWithIconProps {
status: CopyJobStatusType;
}
@@ -42,17 +47,19 @@ const CopyJobStatusWithIcon: React.FC<CopyJobStatusWithIconProps> = React.memo((
CopyJobStatusType.InProgress,
CopyJobStatusType.Partitioning,
].includes(status);
const iconColor = statusIconColors[status] || "var(--colorNeutralForeground2)";
const iconStyle = mergeStyles(iconClass, { color: iconColor });
return (
<Stack horizontal verticalAlign="center">
{isSpinnerStatus ? (
<Spinner size={SpinnerSize.small} style={{ marginRight: "8px" }} />
) : (
<FontIcon aria-label={status} iconName={iconMap[status] || "UnknownSolid"} className={iconStyle} />
<FontIcon
aria-label={status}
iconName={iconMap[status] || "UnknownSolid"}
className={classNames[status] || classNames.unknown}
/>
)}
<Text className="themeText">{statusText}</Text>
<Text>{statusText}</Text>
</Stack>
);
});

View File

@@ -15,8 +15,6 @@ import {
} from "@fluentui/react";
import React, { useEffect } from "react";
import Pager from "../../../../Common/Pager";
import { useThemeStore } from "../../../../hooks/useTheme";
import { getThemeTokens } from "../../../Theme/ThemeUtil";
import { openCopyJobDetailsPanel } from "../../Actions/CopyJobActions";
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
import { getColumns } from "./CopyJobColumns";
@@ -28,15 +26,13 @@ interface CopyJobsListProps {
}
const styles = {
container: { height: "100%" } as React.CSSProperties,
container: { height: "calc(100vh - 25em)" } as React.CSSProperties,
stackItem: { position: "relative", marginBottom: "20px" } as React.CSSProperties,
};
const PAGE_SIZE = 10;
const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pageSize = PAGE_SIZE }) => {
const isDarkMode = useThemeStore((state) => state.isDarkMode);
const themeTokens = getThemeTokens(isDarkMode);
const [startIndex, setStartIndex] = React.useState(0);
const [sortedJobs, setSortedJobs] = React.useState<CopyJobType[]>(jobs);
const [sortedColumnKey, setSortedColumnKey] = React.useState<string | undefined>(undefined);
@@ -92,28 +88,11 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
enableShimmer={false}
constrainMode={ConstrainMode.unconstrained}
layoutMode={DetailsListLayoutMode.justified}
onRenderDetailsHeader={(props, defaultRender) => {
const bgColor = themeTokens.colorNeutralBackground3;
const textColor = themeTokens.colorNeutralForeground1;
return (
<Sticky stickyPosition={StickyPositionType.Header} isScrollSynced stickyBackgroundColor={bgColor}>
<div style={{ backgroundColor: bgColor }}>
{defaultRender({
...props,
styles: {
root: {
backgroundColor: bgColor,
selectors: {
".ms-DetailsHeader-cellTitle": { color: textColor },
".ms-DetailsHeader-cellName": { color: textColor },
},
},
},
})}
</div>
</Sticky>
);
}}
onRenderDetailsHeader={(props, defaultRender) => (
<Sticky stickyPosition={StickyPositionType.Header} isScrollSynced>
{defaultRender({ ...props })}
</Sticky>
)}
/>
</ScrollablePane>
</Stack.Item>

View File

@@ -13,7 +13,7 @@ exports[`CopyJobStatusWithIcon Spinner Status Types renders InProgress with spin
/>
</div>
<span
class="themeText css-112"
class="css-112"
>
Running
</span>
@@ -33,7 +33,7 @@ exports[`CopyJobStatusWithIcon Spinner Status Types renders Partitioning with sp
/>
</div>
<span
class="themeText css-112"
class="css-112"
>
Running
</span>
@@ -53,7 +53,7 @@ exports[`CopyJobStatusWithIcon Spinner Status Types renders Running with spinner
/>
</div>
<span
class="themeText css-112"
class="css-112"
>
Running
</span>
@@ -66,7 +66,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
>
<i
aria-label="Cancelled"
class="ms-Icon root-105 css-118 mocked-styles"
class="ms-Icon root-105 css-118 mocked-style-Cancelled"
data-icon-name="StatusErrorFull"
role="img"
style="font-family: "FabricMDL2Icons-4";"
@@ -74,7 +74,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
</i>
<span
class="themeText css-112"
class="css-112"
>
Cancelled
</span>
@@ -87,7 +87,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
>
<i
aria-label="Completed"
class="ms-Icon root-105 css-120 mocked-styles"
class="ms-Icon root-105 css-120 mocked-style-Completed"
data-icon-name="CompletedSolid"
role="img"
style="font-family: "FabricMDL2Icons-5";"
@@ -95,7 +95,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
</i>
<span
class="themeText css-112"
class="css-112"
>
Completed
</span>
@@ -108,7 +108,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
>
<i
aria-label="Failed"
class="ms-Icon root-105 css-118 mocked-styles"
class="ms-Icon root-105 css-118 mocked-style-Failed"
data-icon-name="StatusErrorFull"
role="img"
style="font-family: "FabricMDL2Icons-4";"
@@ -116,7 +116,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
</i>
<span
class="themeText css-112"
class="css-112"
>
Failed
</span>
@@ -129,7 +129,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
>
<i
aria-label="Faulted"
class="ms-Icon root-105 css-118 mocked-styles"
class="ms-Icon root-105 css-118 mocked-style-Faulted"
data-icon-name="StatusErrorFull"
role="img"
style="font-family: "FabricMDL2Icons-4";"
@@ -137,7 +137,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
</i>
<span
class="themeText css-112"
class="css-112"
>
Failed
</span>
@@ -150,7 +150,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
>
<i
aria-label="Paused"
class="ms-Icon root-105 css-114 mocked-styles"
class="ms-Icon root-105 css-114 mocked-style-Paused"
data-icon-name="CirclePause"
role="img"
style="font-family: "FabricMDL2Icons-11";"
@@ -158,7 +158,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
</i>
<span
class="themeText css-112"
class="css-112"
>
Paused
</span>
@@ -171,7 +171,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
>
<i
aria-label="Pending"
class="ms-Icon root-105 css-111 mocked-styles"
class="ms-Icon root-105 css-111 mocked-style-Pending"
data-icon-name="Clock"
role="img"
style="font-family: "FabricMDL2Icons-2";"
@@ -179,7 +179,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
</i>
<span
class="themeText css-112"
class="css-112"
>
Queued
</span>
@@ -192,7 +192,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
>
<i
aria-label="Skipped"
class="ms-Icon root-105 css-116 mocked-styles"
class="ms-Icon root-105 css-116 mocked-style-Skipped"
data-icon-name="StatusCircleBlock2"
role="img"
style="font-family: "FabricMDL2Icons-9";"
@@ -200,7 +200,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
</i>
<span
class="themeText css-112"
class="css-112"
>
Cancelled
</span>

View File

@@ -10,7 +10,7 @@ import CopyJobsNotFound from "../MonitorCopyJobs/Components/CopyJobs.NotFound";
import { CopyJobType, JobActionUpdatorType } from "../Types/CopyJobTypes";
import CopyJobsList from "./Components/CopyJobsList";
const FETCH_INTERVAL = 2 * 60 * 1000;
const FETCH_INTERVAL_MS = 30 * 1000;
const SHIMMER_INDENT_LEVELS: IndentLevel[] = Array(7).fill({ level: 0, width: "100%" });
interface MonitorCopyJobsProps {
@@ -57,7 +57,7 @@ const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>(({
useEffect(() => {
fetchJobs();
const intervalId = setInterval(fetchJobs, FETCH_INTERVAL);
const intervalId = setInterval(fetchJobs, FETCH_INTERVAL_MS);
return () => clearInterval(intervalId);
}, [fetchJobs]);

View File

@@ -1,30 +1,6 @@
@import "../../../less/Common/Constants.less";
// Common theme-aware classes
.themeText {
color: var(--colorNeutralForeground1);
}
.themeTextSecondary {
color: var(--colorNeutralForeground2);
}
.themeLinkText {
color: var(--colorBrandForeground1);
}
.themeBackground {
background-color: var(--colorNeutralBackground1);
}
.themeBackgroundSecondary {
background-color: var(--colorNeutralBackground2);
}
#containerCopyWrapper {
background-color: var(--colorNeutralBackground1);
color: var(--colorNeutralForeground1);
.centerContent {
justify-content: center;
align-items: center;
@@ -33,30 +9,20 @@
.noCopyJobsMessage {
font-weight: 600;
margin: 0 auto;
color: var(--colorNeutralForeground2);
color: @FocusColor;
}
button.createCopyJobButton {
color: var(--colorBrandForeground1);
color: @LinkColor;
}
}
}
.createCopyJobScreensContainer {
height: 100%;
padding: 1em 1.5em;
background-color: var(--colorNeutralBackground1);
color: var(--colorNeutralForeground1);
.pointInTimeRestoreContainer, .onlineCopyContainer {
position: relative;
}
.toggle-label {
color: var(--colorNeutralForeground1);
}
.accordionHeaderText {
color: var(--colorNeutralForeground1);
}
label {
padding: 0;
@@ -105,7 +71,7 @@
}
.foreground {
z-index: 10;
background-color: var(--colorNeutralBackground2);
background-color: #f9f9f9;
padding: 20px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
transform: translate(0%, -9%);
@@ -114,48 +80,14 @@
.createCopyJobErrorMessageBar {
margin-bottom: 2em;
}
body.isDarkMode & {
.ms-TooltipHost .ms-Image {
filter: invert(1);
}
.ms-TextField {
.ms-TextField-fieldGroup {
background-color: var(--colorNeutralBackground1);
border-color: var(--colorNeutralStroke1);
}
.ms-TextField-field {
color: var(--colorNeutralForeground1);
background-color: var(--colorNeutralBackground1);
&::placeholder {
color: var(--colorNeutralForeground4);
}
}
.ms-Label {
color: var(--colorNeutralForeground1);
}
}
.migrationTypeDescription {
p {
color: var(--colorNeutralForeground1);
}
a {
color: var(--colorBrandForeground1);
}
}
}
.create-container-link-btn {
padding: 0;
height: 25px;
color: var(--colorBrandForeground1);
color: @LinkColor;
&:focus {
outline: none;
}
}
/* Create collection panel */
@@ -173,6 +105,7 @@
width: 100%;
max-width: 100%;
margin: 0 auto;
.ms-DetailsList {
width: 100%;
@@ -181,36 +114,33 @@
padding: @DefaultSpace 20px;
font-weight: 600;
font-size: @DefaultFontSize;
color: var(--colorNeutralForeground1);
background-color: var(--colorNeutralBackground2);
border-bottom: @ButtonBorderWidth solid var(--colorNeutralStroke1);
color: @BaseHigh;
background-color: @BaseLow;
border-bottom: @ButtonBorderWidth solid @BaseMedium;
&:hover {
background-color: var(--colorNeutralBackground3);
background-color: @BaseMediumLow;
}
}
.ms-DetailsHeader-cellTitle {
padding-left: 20px;
}
}
.ms-DetailsRow {
border-bottom: @ButtonBorderWidth solid var(--colorNeutralStroke1);
border-bottom: @ButtonBorderWidth solid @BaseMedium;
&:hover {
background-color: var(--colorNeutralBackground2);
background-color: @BaseMediumLow;
}
.ms-DetailsRow-cell {
padding: @MediumSpace 20px;
font-size: @DefaultFontSize;
color: var(--colorNeutralForeground1);
color: @BaseHigh;
min-height: 48px;
display: flex;
align-items: center;
.jobNameLink {
color: var(--colorBrandForeground1);
color: @LinkColor;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
@@ -238,7 +168,7 @@
}
.ms-DetailsRow-cell {
font-size: @DefaultFontSize;
color: var(--colorNeutralForeground1);
color: @BaseHigh;
}
}
}

View File

@@ -1,8 +1,5 @@
import { IndexingPolicy } from "@azure/cosmos";
import { act } from "@testing-library/react";
import { AuthType } from "AuthType";
import { shallow } from "enzyme";
import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView";
import ko from "knockout";
import React from "react";
import { updateCollection } from "../../../Common/dataAccess/updateCollection";
@@ -447,49 +444,3 @@ describe("SettingsComponent", () => {
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(true);
});
});
describe("SettingsComponent - indexing policy subscription", () => {
const baseProps: SettingsComponentProps = {
settingsTab: new CollectionSettingsTabV2({
collection: collection,
tabKind: ViewModels.CollectionTabKind.CollectionSettingsV2,
title: "Scale & Settings",
tabPath: "",
node: undefined,
}),
};
it("subscribes to the correct container's indexing policy and updates state on change", async () => {
const containerId = collection.id();
const mockIndexingPolicy: IndexingPolicy = {
automatic: false,
indexingMode: "lazy",
includedPaths: [{ path: "/foo/*" }],
excludedPaths: [{ path: "/bar/*" }],
compositeIndexes: [],
spatialIndexes: [],
vectorIndexes: [],
fullTextIndexes: [],
};
const wrapper = shallow(<SettingsComponent {...baseProps} />);
const instance = wrapper.instance() as SettingsComponent;
await act(async () => {
useIndexingPolicyStore.setState({
indexingPolicies: {
[containerId]: mockIndexingPolicy,
},
});
// Wait for the async refreshCollectionData to complete
await new Promise((resolve) => setTimeout(resolve, 0));
});
wrapper.update();
expect(wrapper.state("indexingPolicyContent")).toEqual(mockIndexingPolicy);
expect(wrapper.state("indexingPolicyContentBaseline")).toEqual(mockIndexingPolicy);
// @ts-expect-error: rawDataModel is intentionally accessed for test validation
expect(instance.collection.rawDataModel.indexingPolicy).toEqual(mockIndexingPolicy);
});
});

View File

@@ -13,7 +13,6 @@ import {
ThroughputBucketsComponent,
ThroughputBucketsComponentProps,
} from "Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent";
import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView";
import { useDatabases } from "Explorer/useDatabases";
import { isFabricNative } from "Platform/Fabric/FabricUtil";
import { isCapabilityEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils";
@@ -74,6 +73,7 @@ import {
parseConflictResolutionMode,
parseConflictResolutionProcedure,
} from "./SettingsUtils";
interface SettingsV2TabInfo {
tab: SettingsV2TabTypes;
content: JSX.Element;
@@ -182,7 +182,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
private totalThroughputUsed: number;
private throughputBucketsEnabled: boolean;
public mongoDBCollectionResource: MongoDBCollectionResource;
private unsubscribe: () => void;
constructor(props: SettingsComponentProps) {
super(props);
@@ -312,13 +312,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
if (this.isCollectionSettingsTab) {
this.refreshIndexTransformationProgress();
this.loadMongoIndexes();
this.unsubscribe = useIndexingPolicyStore.subscribe(
() => {
this.refreshCollectionData();
},
(state) => state.indexingPolicies[this.collection?.id()],
);
this.refreshCollectionData();
}
this.setBaseline();
@@ -326,11 +319,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
useCommandBar.getState().setContextButtons(this.getTabsButtons());
}
}
componentWillUnmount(): void {
if (this.unsubscribe) {
this.unsubscribe();
}
}
componentDidUpdate(): void {
if (this.props.settingsTab.isActive()) {
useCommandBar.getState().setContextButtons(this.getTabsButtons());
@@ -860,6 +849,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
{ name: "name_of_property", query: "query_to_compute_property" },
] as DataModels.ComputedProperties;
}
const throughputBuckets = this.offer?.throughputBuckets;
return {
@@ -1019,31 +1009,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
startKey,
);
};
private refreshCollectionData = async (): Promise<void> => {
const containerId = this.collection.id();
const latestIndexingPolicy = useIndexingPolicyStore.getState().indexingPolicies[containerId];
const rawPolicy = latestIndexingPolicy ?? this.collection.indexingPolicy();
const latestCollection: DataModels.IndexingPolicy = {
automatic: rawPolicy?.automatic ?? true,
indexingMode: rawPolicy?.indexingMode ?? "consistent",
includedPaths: rawPolicy?.includedPaths ?? [],
excludedPaths: rawPolicy?.excludedPaths ?? [],
compositeIndexes: rawPolicy?.compositeIndexes ?? [],
spatialIndexes: rawPolicy?.spatialIndexes ?? [],
vectorIndexes: rawPolicy?.vectorIndexes ?? [],
fullTextIndexes: rawPolicy?.fullTextIndexes ?? [],
};
this.collection.rawDataModel.indexingPolicy = latestCollection;
this.setState({
indexingPolicyContent: latestCollection,
indexingPolicyContentBaseline: latestCollection,
});
};
private saveCollectionSettings = async (startKey: number): Promise<void> => {
const newCollection: DataModels.Collection = { ...this.collection.rawDataModel };
if (
this.state.isSubSettingsSaveable ||
this.state.isContainerPolicyDirty ||
@@ -1283,6 +1252,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
onScaleDiscardableChange: this.onScaleDiscardableChange,
throughputError: this.state.throughputError,
};
if (!this.isCollectionSettingsTab) {
return (
<div className="settingsV2MainContainer">

View File

@@ -155,12 +155,7 @@ export class ComputedPropertiesComponent extends React.Component<
</Link>
&#160; about how to define computed properties and how to use them.
</Text>
<div
className="settingsV2Editor"
tabIndex={0}
ref={this.computedPropertiesDiv}
data-test="computed-properties-editor"
></div>
<div className="settingsV2Editor" tabIndex={0} ref={this.computedPropertiesDiv}></div>
</Stack>
);
}

View File

@@ -1,10 +1,10 @@
import { MessageBar, MessageBarType } from "@fluentui/react";
import * as React from "react";
import { handleError } from "../../../../../Common/ErrorHandlingUtils";
import { MessageBar, MessageBarType } from "@fluentui/react";
import {
mongoIndexTransformationRefreshingMessage,
renderMongoIndexTransformationRefreshMessage,
} from "../../SettingsRenderUtils";
import { handleError } from "../../../../../Common/ErrorHandlingUtils";
import { isIndexTransforming } from "../../SettingsUtils";
export interface IndexingPolicyRefreshComponentProps {

View File

@@ -302,8 +302,8 @@ export class SubSettingsComponent extends React.Component<SubSettingsComponentPr
);
private geoSpatialConfigTypeChoiceGroupOptions: IChoiceGroupOption[] = [
{ key: GeospatialConfigType.Geography, text: "Geography", ariaLabel: "geography-option" },
{ key: GeospatialConfigType.Geometry, text: "Geometry", ariaLabel: "geometry-option" },
{ key: GeospatialConfigType.Geography, text: "Geography" },
{ key: GeospatialConfigType.Geometry, text: "Geometry" },
];
private getGeoSpatialComponent = (): JSX.Element => (

View File

@@ -103,10 +103,7 @@ export const ThroughputBucketsComponent: FC<ThroughputBucketsComponentProps> = (
offText="Inactive"
checked={bucket.maxThroughputPercentage !== 100}
onChange={(event, checked) => onToggle(bucket.id, checked)}
styles={{
root: { marginBottom: 0 },
text: { fontSize: 12, color: "var(--colorNeutralForeground1)" },
}}
styles={{ root: { marginBottom: 0 }, text: { fontSize: 12 } }}
></Toggle>
</Stack>
))}

View File

@@ -31,7 +31,6 @@ exports[`ComputedPropertiesComponent renders 1`] = `
</Text>
<div
className="settingsV2Editor"
data-test="computed-properties-editor"
tabIndex={0}
/>
</Stack>

View File

@@ -167,12 +167,10 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},
@@ -654,12 +652,10 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},
@@ -1228,12 +1224,10 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},
@@ -1766,12 +1760,10 @@ exports[`SubSettingsComponent renders 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},
@@ -2338,12 +2330,10 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},

View File

@@ -153,16 +153,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{
@@ -274,16 +264,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{
@@ -496,16 +476,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{
@@ -683,16 +653,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{

View File

@@ -53,7 +53,6 @@ type VectorEmbeddingPolicyProperty = "dataType" | "distanceFunction" | "indexTyp
const labelStyles = {
root: {
fontSize: 12,
color: "var(--colorNeutralForeground1)",
},
};
@@ -64,8 +63,6 @@ const textFieldStyles: IStyleFunctionOrObject<ITextFieldStyleProps, ITextFieldSt
field: {
fontSize: 12,
padding: "0 8px",
backgroundColor: "var(--colorNeutralBackground1)",
color: "var(--colorNeutralForeground1)",
},
};

View File

@@ -853,7 +853,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
{!isSynapseLinkEnabled() && (
<Stack className="panelGroupSpacing">
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
<Text variant="small">
Azure Synapse Link is required for creating an analytical store{" "}
{getCollectionName().toLocaleLowerCase()}. Enable Synapse Link for this Cosmos DB account. <br />
<Link

View File

@@ -475,11 +475,6 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
className="panelGroupSpacing"
>
<Text
style={
{
"color": "var(--colorNeutralForeground1)",
}
}
variant="small"
>
Azure Synapse Link is required for creating an analytical store

View File

@@ -2,7 +2,7 @@ import React from "react";
import LoadingIndicator_3Squares from "../../../images/LoadingIndicator_3Squares.gif";
export const PanelLoadingScreen: React.FunctionComponent = () => (
<div id="loadingScreen" className="dataExplorerLoaderContainer dataExplorerLoaderforcopyJobs">
<div id="loadingScreen" className="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer">
<img className="dataExplorerLoader" src={LoadingIndicator_3Squares} />
</div>
);

View File

@@ -205,7 +205,7 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
tooltip="Select one or more JSON files to upload. Each file can contain a single JSON document or an array of JSON documents. The combined size of all files in an individual upload operation must be less than 2 MB. You can perform multiple upload operations for larger data sets."
/>
{uploadFileData?.length > 0 && (
<div className="fileUploadSummaryContainer" data-test="file-upload-status">
<div className="fileUploadSummaryContainer">
<b style={{ color: "var(--colorNeutralForeground1)" }}>File upload status</b>
<DetailsList
items={uploadFileData}

View File

@@ -1,107 +0,0 @@
import { CircleFilled } from "@fluentui/react-icons";
import type { IIndexMetric } from "Explorer/Tabs/QueryTab/ResultsView";
import { useIndexAdvisorStyles } from "Explorer/Tabs/QueryTab/StylesAdvisor";
import * as React from "react";
// SDK response format
export interface IndexMetricsResponse {
UtilizedIndexes?: {
SingleIndexes?: Array<{ IndexSpec: string; IndexImpactScore?: string }>;
CompositeIndexes?: Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>;
};
PotentialIndexes?: {
SingleIndexes?: Array<{ IndexSpec: string; IndexImpactScore?: string }>;
CompositeIndexes?: Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>;
};
}
export function parseIndexMetrics(indexMetrics: IndexMetricsResponse): {
included: IIndexMetric[];
notIncluded: IIndexMetric[];
} {
const included: IIndexMetric[] = [];
const notIncluded: IIndexMetric[] = [];
// Process UtilizedIndexes (Included in Current Policy)
if (indexMetrics.UtilizedIndexes) {
// Single indexes
indexMetrics.UtilizedIndexes.SingleIndexes?.forEach((index) => {
included.push({
index: index.IndexSpec,
impact: index.IndexImpactScore || "Utilized",
section: "Included",
path: index.IndexSpec,
});
});
// Composite indexes
indexMetrics.UtilizedIndexes.CompositeIndexes?.forEach((index) => {
const compositeSpec = index.IndexSpecs.join(", ");
included.push({
index: compositeSpec,
impact: index.IndexImpactScore || "Utilized",
section: "Included",
composite: index.IndexSpecs.map((spec) => {
const [path, order] = spec.trim().split(/\s+/);
return {
path: path.trim(),
order: order?.toLowerCase() === "desc" ? "descending" : "ascending",
};
}),
});
});
}
// Process PotentialIndexes (Not Included in Current Policy)
if (indexMetrics.PotentialIndexes) {
// Single indexes
indexMetrics.PotentialIndexes.SingleIndexes?.forEach((index) => {
notIncluded.push({
index: index.IndexSpec,
impact: index.IndexImpactScore || "Unknown",
section: "Not Included",
path: index.IndexSpec,
});
});
// Composite indexes
indexMetrics.PotentialIndexes.CompositeIndexes?.forEach((index) => {
const compositeSpec = index.IndexSpecs.join(", ");
notIncluded.push({
index: compositeSpec,
impact: index.IndexImpactScore || "Unknown",
section: "Not Included",
composite: index.IndexSpecs.map((spec) => {
const [path, order] = spec.trim().split(/\s+/);
return {
path: path.trim(),
order: order?.toLowerCase() === "desc" ? "descending" : "ascending",
};
}),
});
});
}
return { included, notIncluded };
}
export const renderImpactDots = (impact: string): JSX.Element => {
const style = useIndexAdvisorStyles();
let count = 0;
if (impact === "High") {
count = 3;
} else if (impact === "Medium") {
count = 2;
} else if (impact === "Low") {
count = 1;
}
return (
<div className={style.indexAdvisorImpactDots}>
{Array.from({ length: count }).map((_, i) => (
<CircleFilled key={i} className={style.indexAdvisorImpactDot} />
))}
</div>
);
};

View File

@@ -3,21 +3,18 @@ import QueryError from "Common/QueryError";
import { IndeterminateProgressBar } from "Explorer/Controls/IndeterminateProgressBar";
import { MessageBanner } from "Explorer/Controls/MessageBanner";
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
import useZoomLevel from "hooks/useZoomLevel";
import React from "react";
import { conditionalClass } from "Utils/StyleUtils";
import RunQuery from "../../../../images/RunQuery.png";
import { QueryResults } from "../../../Contracts/ViewModels";
import { ErrorList } from "./ErrorList";
import { ResultsView } from "./ResultsView";
import useZoomLevel from "hooks/useZoomLevel";
import { conditionalClass } from "Utils/StyleUtils";
export interface ResultsViewProps {
isMongoDB: boolean;
queryResults: QueryResults;
executeQueryDocumentsPage: (firstItemIndex: number) => Promise<void>;
queryEditorContent?: string;
databaseId?: string;
containerId?: string;
}
interface QueryResultProps extends ResultsViewProps {
@@ -52,8 +49,6 @@ export const QueryResultSection: React.FC<QueryResultProps> = ({
queryResults,
executeQueryDocumentsPage,
isExecuting,
databaseId,
containerId,
}: QueryResultProps): JSX.Element => {
const styles = useQueryTabStyles();
const maybeSubQuery = queryEditorContent && /.*\(.*SELECT.*\)/i.test(queryEditorContent);
@@ -96,9 +91,6 @@ export const QueryResultSection: React.FC<QueryResultProps> = ({
queryResults={queryResults}
executeQueryDocumentsPage={executeQueryDocumentsPage}
isMongoDB={isMongoDB}
queryEditorContent={queryEditorContent}
databaseId={databaseId}
containerId={containerId}
/>
) : (
<ExecuteQueryCallToAction />

View File

@@ -52,9 +52,8 @@ describe("QueryTabComponent", () => {
copilotVersion: "v3.0",
},
});
const propsMock: Readonly<IQueryTabComponentProps> = {
collection: { databaseId: "CopilotSampleDB", id: () => "CopilotContainer" },
collection: { databaseId: "CopilotSampleDB" },
onTabAccessor: () => jest.fn(),
isExecutionError: false,
tabId: "mockTabId",

View File

@@ -28,7 +28,6 @@ import { useMonacoTheme } from "hooks/useTheme";
import React, { Fragment, createRef } from "react";
import "react-splitter-layout/lib/index.css";
import { format } from "react-string-format";
import create from "zustand";
//TODO: Uncomment next two lines when query copilot is reinstated in DE
// import QueryCommandIcon from "../../../../images/CopilotCommand.svg";
// import LaunchCopilot from "../../../../images/CopilotTabIcon.svg";
@@ -58,20 +57,6 @@ import { SaveQueryPane } from "../../Panes/SaveQueryPane/SaveQueryPane";
import TabsBase from "../TabsBase";
import "./QueryTabComponent.less";
export interface QueryMetadataStore {
userQuery: string;
databaseId: string;
containerId: string;
setMetadata: (query1: string, db: string, container: string) => void;
}
export const useQueryMetadataStore = create<QueryMetadataStore>((set) => ({
userQuery: "",
databaseId: "",
containerId: "",
setMetadata: (query1, db, container) => set({ userQuery: query1, databaseId: db, containerId: container }),
}));
enum ToggleState {
Result,
QueryMetrics,
@@ -279,10 +264,6 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
}
public onExecuteQueryClick = async (): Promise<void> => {
const query1 = this.state.sqlQueryEditorContent;
const db = this.props.collection.databaseId;
const container = this.props.collection.id();
useQueryMetadataStore.getState().setMetadata(query1, db, container);
this._iterator = undefined;
setTimeout(async () => {
@@ -799,8 +780,6 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
errors={this.props.copilotStore?.errors}
isExecuting={this.props.copilotStore?.isExecuting}
queryResults={this.props.copilotStore?.queryResults}
databaseId={this.props.collection.databaseId}
containerId={this.props.collection.id()}
executeQueryDocumentsPage={(firstItemIndex: number) =>
QueryDocumentsPerPage(
firstItemIndex,
@@ -816,8 +795,6 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
errors={this.state.errors}
isExecuting={this.state.isExecuting}
queryResults={this.state.queryResults}
databaseId={this.props.collection.databaseId}
containerId={this.props.collection.id()}
executeQueryDocumentsPage={(firstItemIndex: number) =>
this._executeQueryDocumentsPage(firstItemIndex)
}

View File

@@ -1,170 +0,0 @@
import "@testing-library/jest-dom";
import { render, screen, waitFor } from "@testing-library/react";
import { IndexAdvisorTab } from "Explorer/Tabs/QueryTab/ResultsView";
import React from "react";
const mockReplace = jest.fn();
const mockFetchAll = jest.fn();
const mockRead = jest.fn();
const mockLogConsoleProgress = jest.fn();
const mockHandleError = jest.fn();
const indexMetricsResponse = {
UtilizedIndexes: {
SingleIndexes: [{ IndexSpec: "/foo/?", IndexImpactScore: "High" }],
CompositeIndexes: [{ IndexSpecs: ["/baz/? DESC", "/qux/? ASC"], IndexImpactScore: "Low" }],
},
PotentialIndexes: {
SingleIndexes: [{ IndexSpec: "/bar/?", IndexImpactScore: "Medium" }],
CompositeIndexes: [] as Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>,
},
};
const mockQueryResults = {
documents: [] as unknown[],
hasMoreResults: false,
itemCount: 0,
firstItemIndex: 0,
lastItemIndex: 0,
requestCharge: 0,
activityId: "test-activity-id",
};
mockRead.mockResolvedValue({
resource: {
indexingPolicy: {
automatic: true,
indexingMode: "consistent",
includedPaths: [{ path: "/*" }, { path: "/foo/?" }],
excludedPaths: [],
},
partitionKey: "pk",
},
});
mockReplace.mockResolvedValue({
resource: {
indexingPolicy: {
automatic: true,
indexingMode: "consistent",
includedPaths: [{ path: "/*" }],
excludedPaths: [],
},
},
});
jest.mock("Common/CosmosClient", () => ({
client: () => ({
database: () => ({
container: () => ({
items: {
query: () => ({
fetchAll: mockFetchAll,
}),
},
read: mockRead,
replace: mockReplace,
}),
}),
}),
}));
jest.mock("./StylesAdvisor", () => ({
useIndexAdvisorStyles: () => ({}),
}));
jest.mock("../../../Utils/NotificationConsoleUtils", () => ({
logConsoleProgress: (...args: unknown[]) => {
mockLogConsoleProgress(...args);
return () => {};
},
}));
jest.mock("../../../Common/ErrorHandlingUtils", () => ({
handleError: (...args: unknown[]) => mockHandleError(...args),
}));
beforeEach(() => {
jest.clearAllMocks();
mockFetchAll.mockResolvedValue({ indexMetrics: indexMetricsResponse });
});
describe("IndexAdvisorTab Basic Tests", () => {
test("component renders without crashing", () => {
const { container } = render(
<IndexAdvisorTab queryEditorContent="SELECT * FROM c" databaseId="db1" containerId="col1" />,
);
expect(container).toBeTruthy();
});
test("renders component and handles missing parameters", () => {
const { container } = render(<IndexAdvisorTab />);
expect(container).toBeTruthy();
// Should not crash when parameters are missing
});
test("fetches index metrics with query results", async () => {
render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="db1"
containerId="col1"
/>,
);
await waitFor(() => expect(mockFetchAll).toHaveBeenCalled());
});
test("displays content after loading", async () => {
render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="db1"
containerId="col1"
/>,
);
// Wait for the component to finish loading
await waitFor(() => expect(mockFetchAll).toHaveBeenCalled());
// Component should have rendered some content
expect(screen.getByText(/Index Advisor/i)).toBeInTheDocument();
});
test("calls log console progress when fetching metrics", async () => {
render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="db1"
containerId="col1"
/>,
);
await waitFor(() => expect(mockLogConsoleProgress).toHaveBeenCalled());
});
test("handles error when fetch fails", async () => {
mockFetchAll.mockRejectedValueOnce(new Error("fetch failed"));
render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="db1"
containerId="col1"
/>,
);
await waitFor(() => expect(mockHandleError).toHaveBeenCalled(), { timeout: 3000 });
});
test("renders with all required props", () => {
const { container } = render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="testDb"
containerId="testContainer"
/>,
);
expect(container).toBeTruthy();
expect(container.firstChild).toBeTruthy();
});
});

View File

@@ -1,8 +1,5 @@
import type { CompositePath, IndexingPolicy } from "@azure/cosmos";
import { FontIcon } from "@fluentui/react";
import {
Button,
Checkbox,
DataGrid,
DataGridBody,
DataGridCell,
@@ -11,45 +8,28 @@ import {
DataGridRow,
SelectTabData,
SelectTabEvent,
Spinner,
Tab,
TabList,
Table,
TableBody,
TableCell,
TableColumnDefinition,
TableHeader,
TableRow,
createTableColumn,
} from "@fluentui/react-components";
import { ArrowDownloadRegular, ChevronDown20Regular, ChevronRight20Regular, CopyRegular } from "@fluentui/react-icons";
import copy from "clipboard-copy";
import { ArrowDownloadRegular, CopyRegular } from "@fluentui/react-icons";
import { HttpHeaders } from "Common/Constants";
import MongoUtility from "Common/MongoUtility";
import { QueryMetrics } from "Contracts/DataModels";
import { QueryResults } from "Contracts/ViewModels";
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
import {
parseIndexMetrics,
renderImpactDots,
type IndexMetricsResponse,
} from "Explorer/Tabs/QueryTab/IndexAdvisorUtils";
import { IDocument } from "Explorer/Tabs/QueryTab/QueryTabComponent";
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
import React, { useCallback, useEffect, useState } from "react";
import { userContext } from "UserContext";
import { logConsoleProgress } from "Utils/NotificationConsoleUtils";
import create from "zustand";
import { client } from "../../../Common/CosmosClient";
import { handleError } from "../../../Common/ErrorHandlingUtils";
import { sampleDataClient } from "../../../Common/SampleDataClient";
import copy from "clipboard-copy";
import React, { useCallback, useState } from "react";
import { ResultsViewProps } from "./QueryResultSection";
import { useIndexAdvisorStyles } from "./StylesAdvisor";
enum ResultsTabs {
Results = "results",
QueryStats = "queryStats",
IndexAdvisor = "indexadv",
}
const ResultsTab: React.FC<ResultsViewProps> = ({ queryResults, isMongoDB, executeQueryDocumentsPage }) => {
const styles = useQueryTabStyles();
/* eslint-disable react/prop-types */
@@ -543,331 +523,14 @@ const QueryStatsTab: React.FC<Pick<ResultsViewProps, "queryResults">> = ({ query
);
};
export interface IIndexMetric {
index: string;
impact: string;
section: "Included" | "Not Included" | "Header";
path?: string;
composite?: { path: string; order: string }[];
}
export const IndexAdvisorTab: React.FC<{
queryResults?: QueryResults;
queryEditorContent?: string;
databaseId?: string;
containerId?: string;
}> = ({ queryResults, queryEditorContent, databaseId, containerId }) => {
const style = useIndexAdvisorStyles();
const [loading, setLoading] = useState(false);
const [indexMetrics, setIndexMetrics] = useState<IndexMetricsResponse | null>(null);
const [showIncluded, setShowIncluded] = useState(true);
const [showNotIncluded, setShowNotIncluded] = useState(true);
const [selectedIndexes, setSelectedIndexes] = useState<IIndexMetric[]>([]);
const [selectAll, setSelectAll] = useState(false);
const [updateMessageShown, setUpdateMessageShown] = useState(false);
const [included, setIncludedIndexes] = useState<IIndexMetric[]>([]);
const [notIncluded, setNotIncludedIndexes] = useState<IIndexMetric[]>([]);
const [isUpdating, setIsUpdating] = useState(false);
const [justUpdatedPolicy, setJustUpdatedPolicy] = useState(false);
const indexingMetricsDocLink = "https://learn.microsoft.com/azure/cosmos-db/nosql/index-metrics";
const fetchIndexMetrics = async () => {
if (!queryEditorContent || !databaseId || !containerId) {
return;
}
setLoading(true);
const clearMessage = logConsoleProgress(`Querying items with IndexMetrics in container ${containerId}`);
try {
const querySpec = {
query: queryEditorContent,
};
// Use sampleDataClient for CopilotSampleDB, regular client for other databases
const cosmosClient = databaseId === "CopilotSampleDB" ? sampleDataClient() : client();
const sdkResponse = await cosmosClient
.database(databaseId)
.container(containerId)
.items.query(querySpec, {
populateIndexMetrics: true,
})
.fetchAll();
const parsedMetrics =
typeof sdkResponse.indexMetrics === "string" ? JSON.parse(sdkResponse.indexMetrics) : sdkResponse.indexMetrics;
setIndexMetrics(parsedMetrics);
} catch (error) {
handleError(error, "queryItemsWithIndexMetrics", `Error querying items from ${containerId}`);
} finally {
clearMessage();
setLoading(false);
}
};
// Fetch index metrics when query results change (i.e., when Execute Query is clicked)
useEffect(() => {
if (queryEditorContent && databaseId && containerId && queryResults) {
fetchIndexMetrics();
}
}, [queryResults]);
useEffect(() => {
if (!indexMetrics) {
return;
}
const { included, notIncluded } = parseIndexMetrics(indexMetrics);
setIncludedIndexes(included);
setNotIncludedIndexes(notIncluded);
if (justUpdatedPolicy) {
setJustUpdatedPolicy(false);
} else {
setUpdateMessageShown(false);
}
}, [indexMetrics]);
useEffect(() => {
const allSelected =
notIncluded.length > 0 && notIncluded.every((item) => selectedIndexes.some((s) => s.index === item.index));
setSelectAll(allSelected);
}, [selectedIndexes, notIncluded]);
const handleCheckboxChange = (indexObj: IIndexMetric, checked: boolean) => {
if (checked) {
setSelectedIndexes((prev) => [...prev, indexObj]);
} else {
setSelectedIndexes((prev) => prev.filter((item) => item.index !== indexObj.index));
}
};
const handleSelectAll = (checked: boolean) => {
setSelectAll(checked);
setSelectedIndexes(checked ? notIncluded : []);
};
const handleUpdatePolicy = async () => {
setIsUpdating(true);
try {
const containerRef = client().database(databaseId).container(containerId);
const { resource: containerDef } = await containerRef.read();
const newIncludedPaths = selectedIndexes
.filter((index) => !index.composite)
.map((index) => {
return {
path: index.path,
};
});
const newCompositeIndexes: CompositePath[][] = selectedIndexes
.filter((index) => Array.isArray(index.composite))
.map(
(index) =>
(index.composite as { path: string; order: string }[]).map((comp) => ({
path: comp.path,
order: comp.order === "descending" ? "descending" : "ascending",
})) as CompositePath[],
);
const updatedPolicy: IndexingPolicy = {
...containerDef.indexingPolicy,
includedPaths: [...(containerDef.indexingPolicy?.includedPaths || []), ...newIncludedPaths],
compositeIndexes: [...(containerDef.indexingPolicy?.compositeIndexes || []), ...newCompositeIndexes],
automatic: containerDef.indexingPolicy?.automatic ?? true,
indexingMode: containerDef.indexingPolicy?.indexingMode ?? "consistent",
excludedPaths: containerDef.indexingPolicy?.excludedPaths ?? [],
};
await containerRef.replace({
id: containerId,
partitionKey: containerDef.partitionKey,
indexingPolicy: updatedPolicy,
});
useIndexingPolicyStore.getState().setIndexingPolicyFor(containerId, updatedPolicy);
const selectedIndexSet = new Set(selectedIndexes.map((s) => s.index));
const updatedNotIncluded: typeof notIncluded = [];
const newlyIncluded: typeof included = [];
for (const item of notIncluded) {
if (selectedIndexSet.has(item.index)) {
newlyIncluded.push(item);
} else {
updatedNotIncluded.push(item);
}
}
const newIncluded = [...included, ...newlyIncluded];
const newNotIncluded = updatedNotIncluded;
setIncludedIndexes(newIncluded);
setNotIncludedIndexes(newNotIncluded);
setSelectedIndexes([]);
setSelectAll(false);
setUpdateMessageShown(true);
setJustUpdatedPolicy(true);
} catch (err) {
console.error("Failed to update indexing policy:", err);
} finally {
setIsUpdating(false);
}
};
const renderRow = (item: IIndexMetric, index: number) => {
const isHeader = item.section === "Header";
const isNotIncluded = item.section === "Not Included";
return (
<TableRow key={index}>
<TableCell colSpan={2}>
<div className={style.indexAdvisorGrid}>
{isNotIncluded ? (
<Checkbox
checked={selectedIndexes.some((selected) => selected.index === item.index)}
onChange={(_, data) => handleCheckboxChange(item, data.checked === true)}
/>
) : isHeader && item.index === "Not Included in Current Policy" && notIncluded.length > 0 ? (
<Checkbox checked={selectAll} onChange={(_, data) => handleSelectAll(data.checked === true)} />
) : (
<div className={style.indexAdvisorCheckboxSpacer}></div>
)}
{isHeader ? (
<span
style={{ cursor: "pointer" }}
onClick={() => {
if (item.index === "Included in Current Policy") {
setShowIncluded(!showIncluded);
} else if (item.index === "Not Included in Current Policy") {
setShowNotIncluded(!showNotIncluded);
}
}}
>
{item.index === "Included in Current Policy" ? (
showIncluded ? (
<ChevronDown20Regular />
) : (
<ChevronRight20Regular />
)
) : showNotIncluded ? (
<ChevronDown20Regular />
) : (
<ChevronRight20Regular />
)}
</span>
) : (
<div className={style.indexAdvisorChevronSpacer}></div>
)}
<div className={isHeader ? style.indexAdvisorRowBold : style.indexAdvisorRowNormal}>{item.index}</div>
<div className={isHeader ? style.indexAdvisorRowImpactHeader : style.indexAdvisorRowImpact}>
{!isHeader && item.impact}
</div>
<div>{!isHeader && renderImpactDots(item.impact)}</div>
</div>
</TableCell>
</TableRow>
);
};
const indexMetricItems = React.useMemo(() => {
const items: IIndexMetric[] = [];
items.push({ index: "Not Included in Current Policy", impact: "", section: "Header" });
if (showNotIncluded) {
notIncluded.forEach((item) => items.push({ ...item, section: "Not Included" }));
}
items.push({ index: "Included in Current Policy", impact: "", section: "Header" });
if (showIncluded) {
included.forEach((item) => items.push({ ...item, section: "Included" }));
}
return items;
}, [included, notIncluded, showIncluded, showNotIncluded]);
if (loading) {
return (
<div>
<Spinner
size="small"
style={
{
"--spinner-size": "16px",
"--spinner-thickness": "2px",
"--spinner-color": "#0078D4",
} as React.CSSProperties
}
/>
</div>
);
}
return (
<div>
<div className={style.indexAdvisorMessage}>
{updateMessageShown ? (
<>
<span className={style.indexAdvisorSuccessIcon}>
<FontIcon iconName="CheckMark" style={{ color: "white", fontSize: 12 }} />
</span>
<span>
Your indexing policy has been updated with the new included paths. You may review the changes in Scale &
Settings.
</span>
</>
) : (
<>
<span>
Index Advisor uses Indexing Metrics to suggest query paths that, when included in your indexing policy,
can improve the performance of this query by reducing RU costs and lowering latency.{" "}
<a href={indexingMetricsDocLink} target="_blank" rel="noopener noreferrer">
Learn more about Indexing Metrics
</a>
.{" "}
</span>
</>
)}
</div>
<div className={style.indexAdvisorTitle}>Indexes analysis</div>
<Table className={style.indexAdvisorTable}>
<TableHeader>
<TableRow>
<TableCell colSpan={2}>
<div className={style.indexAdvisorGrid}>
<div className={style.indexAdvisorCheckboxSpacer}></div>
<div className={style.indexAdvisorChevronSpacer}></div>
<div>Index</div>
<div>
<span style={{ whiteSpace: "nowrap" }}>Estimated Impact</span>
</div>
</div>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>{indexMetricItems.map(renderRow)}</TableBody>
</Table>
{selectedIndexes.length > 0 && (
<div className={style.indexAdvisorButtonBar}>
{isUpdating ? (
<div className={style.indexAdvisorButtonSpinner}>
<Spinner size="tiny" />{" "}
</div>
) : (
<button onClick={handleUpdatePolicy} className={style.indexAdvisorButton}>
Update Indexing Policy with selected index(es)
</button>
)}
</div>
)}
</div>
);
};
export const ResultsView: React.FC<ResultsViewProps> = ({
isMongoDB,
queryResults,
executeQueryDocumentsPage,
queryEditorContent,
databaseId,
containerId,
}) => {
export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResults, executeQueryDocumentsPage }) => {
const styles = useQueryTabStyles();
const [activeTab, setActiveTab] = useState<ResultsTabs>(ResultsTabs.Results);
const onTabSelect = useCallback((event: SelectTabEvent, data: SelectTabData) => {
setActiveTab(data.value as ResultsTabs);
}, []);
return (
<div data-test="QueryTab/ResultsPane/ResultsView" className={styles.queryResultsTabPanel}>
<TabList selectedValue={activeTab} onTabSelect={onTabSelect}>
@@ -885,13 +548,6 @@ export const ResultsView: React.FC<ResultsViewProps> = ({
>
Query Stats
</Tab>
<Tab
data-test="QueryTab/ResultsPane/ResultsView/IndexAdvisorTab"
id={ResultsTabs.IndexAdvisor}
value={ResultsTabs.IndexAdvisor}
>
Index Advisor
</Tab>
</TabList>
<div className={styles.queryResultsTabContentContainer}>
{activeTab === ResultsTabs.Results && (
@@ -902,30 +558,7 @@ export const ResultsView: React.FC<ResultsViewProps> = ({
/>
)}
{activeTab === ResultsTabs.QueryStats && <QueryStatsTab queryResults={queryResults} />}
{activeTab === ResultsTabs.IndexAdvisor && (
<IndexAdvisorTab
queryResults={queryResults}
queryEditorContent={queryEditorContent}
databaseId={databaseId}
containerId={containerId}
/>
)}
</div>
</div>
);
};
export interface IndexingPolicyStore {
indexingPolicies: { [containerId: string]: IndexingPolicy };
setIndexingPolicyFor: (containerId: string, indexingPolicy: IndexingPolicy) => void;
}
export const useIndexingPolicyStore = create<IndexingPolicyStore>((set) => ({
indexingPolicies: {},
setIndexingPolicyFor: (containerId, indexingPolicy) =>
set((state) => ({
indexingPolicies: {
...state.indexingPolicies,
[containerId]: { ...indexingPolicy },
},
})),
}));

View File

@@ -1,95 +0,0 @@
import { makeStyles } from "@fluentui/react-components";
export type IndexAdvisorStyles = ReturnType<typeof useIndexAdvisorStyles>;
export const useIndexAdvisorStyles = makeStyles({
indexAdvisorMessage: {
padding: "1rem",
fontSize: "1.2rem",
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
indexAdvisorSuccessIcon: {
width: "18px",
height: "18px",
borderRadius: "50%",
backgroundColor: "#107C10",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
indexAdvisorTitle: {
padding: "1rem",
fontSize: "1.3rem",
fontWeight: "bold",
},
indexAdvisorTable: {
display: "block",
alignItems: "center",
marginBottom: "7rem",
},
indexAdvisorGrid: {
display: "grid",
gridTemplateColumns: "30px 30px 1fr 50px 120px",
alignItems: "center",
gap: "15px",
fontWeight: "bold",
},
indexAdvisorCheckboxSpacer: {
width: "18px",
height: "18px",
},
indexAdvisorChevronSpacer: {
width: "24px",
},
indexAdvisorRowBold: {
fontWeight: "bold",
},
indexAdvisorRowNormal: {
fontWeight: "normal",
},
indexAdvisorRowImpactHeader: {
fontSize: 0,
},
indexAdvisorRowImpact: {
fontWeight: "normal",
},
indexAdvisorImpactDot: {
color: "#0078D4",
fontSize: "12px",
display: "inline-flex",
},
indexAdvisorImpactDots: {
display: "flex",
alignItems: "center",
gap: "4px",
},
indexAdvisorButtonBar: {
padding: "1rem",
marginTop: "-7rem",
flexWrap: "wrap",
},
indexAdvisorButtonSpinner: {
marginTop: "1rem",
minWidth: "320px",
minHeight: "40px",
display: "flex",
alignItems: "left",
justifyContent: "left",
marginLeft: "10rem",
},
indexAdvisorButton: {
backgroundColor: "#0078D4",
color: "white",
padding: "8px 16px",
border: "none",
borderRadius: "4px",
cursor: "pointer",
marginTop: "1rem",
fontSize: "1rem",
fontWeight: 500,
transition: "background 0.2s",
":hover": {
backgroundColor: "#005a9e",
},
},
});

View File

@@ -1,15 +0,0 @@
import create from "zustand";
interface QueryMetadataStore {
userQuery: string;
databaseId: string;
containerId: string;
setMetadata: (query1: string, db: string, container: string) => void;
}
export const useQueryMetadataStore = create<QueryMetadataStore>((set) => ({
userQuery: "",
databaseId: "",
containerId: "",
setMetadata: (query1, db, container) => set({ userQuery: query1, databaseId: db, containerId: container }),
}));

View File

@@ -435,6 +435,7 @@ export default class StoredProcedureTabComponent extends React.Component<
});
useCommandBar.getState().setContextButtons(this.getTabsButtons());
}, 100);
return createdResource;
},
(createError) => {

View File

@@ -1,7 +1,6 @@
import { Metric, onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals";
import { configContext } from "../ConfigContext";
import { Action } from "../Shared/Telemetry/TelemetryConstants";
import { traceFailure, traceMark, traceStart, traceSuccess } from "../Shared/Telemetry/TelemetryProcessor";
import { trackEvent } from "../Shared/appInsights";
import { userContext } from "../UserContext";
import MetricScenario, { reportHealthy, reportUnhealthy } from "./MetricEvents";
import { scenarioConfigs } from "./MetricScenarioConfigs";
@@ -84,13 +83,6 @@ class ScenarioMonitor {
ctx.phases.set(phase, { startMarkName: phaseStartMarkName });
});
traceMark(Action.MetricsScenario, {
event: "scenario_start",
scenario,
requiredPhases: config.requiredPhases.join(","),
timeoutMs: config.timeoutMs,
});
ctx.timeoutId = window.setTimeout(() => this.emit(ctx, false, true), config.timeoutMs);
this.contexts.set(scenario, ctx);
}
@@ -104,12 +96,6 @@ class ScenarioMonitor {
const startMarkName = `scenario_${scenario}_${phase}_start`;
performance.mark(startMarkName);
ctx.phases.set(phase, { startMarkName });
traceStart(Action.MetricsScenario, {
event: "phase_start",
scenario,
phase,
});
}
completePhase(scenario: MetricScenario, phase: MetricPhase) {
@@ -124,22 +110,6 @@ class ScenarioMonitor {
phaseCtx.endMarkName = endMarkName;
ctx.completed.add(phase);
const navigationStart = performance.timeOrigin;
const startEntry = performance.getEntriesByName(phaseCtx.startMarkName)[0];
const endEntry = performance.getEntriesByName(endMarkName)[0];
const endTimeISO = endEntry ? new Date(navigationStart + endEntry.startTime).toISOString() : undefined;
const durationMs = startEntry && endEntry ? endEntry.startTime - startEntry.startTime : undefined;
traceSuccess(Action.MetricsScenario, {
event: "phase_complete",
scenario,
phase,
endTimeISO,
durationMs,
completedCount: ctx.completed.size,
requiredCount: ctx.config.requiredPhases.length,
});
this.tryEmitIfReady(ctx);
}
@@ -163,14 +133,6 @@ class ScenarioMonitor {
// Build a snapshot with failure info
const failureSnapshot = this.buildSnapshot(ctx, { final: false, timedOut: false });
traceFailure(Action.MetricsScenario, {
event: "phase_fail",
scenario,
phase,
failedPhases: Array.from(ctx.failed).join(","),
completedPhases: Array.from(ctx.completed).join(","),
});
// Emit unhealthy immediately
this.emit(ctx, false, false, failureSnapshot);
}
@@ -229,22 +191,27 @@ class ScenarioMonitor {
// Build snapshot if not provided
const finalSnapshot = snapshot || this.buildSnapshot(ctx, { final: false, timedOut });
traceMark(Action.MetricsScenario, {
event: "scenario_end",
scenario: ctx.scenario,
healthy,
timedOut,
platform,
api,
durationMs: finalSnapshot.durationMs,
completedPhases: finalSnapshot.completed.join(","),
failedPhases: finalSnapshot.failedPhases?.join(","),
lcp: finalSnapshot.vitals?.lcp,
inp: finalSnapshot.vitals?.inp,
cls: finalSnapshot.vitals?.cls,
fcp: finalSnapshot.vitals?.fcp,
ttfb: finalSnapshot.vitals?.ttfb,
});
// Emit enriched telemetry with performance data
// TODO: Call portal backend metrics endpoint
trackEvent(
{ name: "MetricScenarioComplete" },
{
scenario: ctx.scenario,
healthy: healthy.toString(),
timedOut: timedOut.toString(),
platform,
api,
durationMs: finalSnapshot.durationMs.toString(),
completedPhases: finalSnapshot.completed.join(","),
failedPhases: finalSnapshot.failedPhases?.join(","),
lcp: finalSnapshot.vitals?.lcp?.toString(),
inp: finalSnapshot.vitals?.inp?.toString(),
cls: finalSnapshot.vitals?.cls?.toString(),
fcp: finalSnapshot.vitals?.fcp?.toString(),
ttfb: finalSnapshot.vitals?.ttfb?.toString(),
phaseTimings: JSON.stringify(finalSnapshot.phaseTimings),
},
);
// Call portal backend health metrics endpoint
if (healthy && !timedOut) {
@@ -260,16 +227,9 @@ class ScenarioMonitor {
private cleanupPerformanceEntries(ctx: InternalScenarioContext) {
performance.clearMarks(ctx.startMarkName);
ctx.config.requiredPhases.forEach((phase) => {
const phaseCtx = ctx.phases.get(phase);
if (phaseCtx?.startMarkName) {
performance.clearMarks(phaseCtx.startMarkName);
}
if (phaseCtx?.endMarkName) {
performance.clearMarks(phaseCtx.endMarkName);
}
performance.clearMarks(`scenario_${ctx.scenario}_${phase}_failed`);
performance.clearMeasures(`scenario_${ctx.scenario}_${phase}_duration`);
performance.clearMarks(`scenario_${ctx.scenario}_${phase}`);
});
performance.clearMeasures(`scenario_${ctx.scenario}_total`);
}
private buildSnapshot(

View File

@@ -1,6 +1,6 @@
import { Dropdown } from "@fluentui/react";
import * as React from "react";
import { FunctionComponent } from "react";
import { SearchableDropdown } from "../../../Common/SearchableDropdown";
import { DatabaseAccount } from "../../../Contracts/DataModels";
interface Props {
@@ -17,23 +17,18 @@ export const SwitchAccount: FunctionComponent<Props> = ({
dismissMenu,
}: Props) => {
return (
<Dropdown
<SearchableDropdown<DatabaseAccount>
label="Cosmos DB Account Name"
items={accounts}
selectedItem={selectedAccount}
onSelect={(account) => setSelectedAccountName(account.name)}
getKey={(account) => account.name}
getDisplayText={(account) => account.name}
placeholder="Select an Account"
filterPlaceholder="Filter accounts"
className="accountSwitchAccountDropdown"
options={accounts?.map((account) => ({
key: account.name,
text: account.name,
data: account,
}))}
onChange={(_, option) => {
setSelectedAccountName(String(option?.key));
dismissMenu();
}}
defaultSelectedKey={selectedAccount?.name}
placeholder={accounts && accounts.length === 0 ? "No Accounts Found" : "Select an Account"}
styles={{
callout: "accountSwitchAccountDropdownMenu",
}}
disabled={!accounts || accounts.length === 0}
onDismiss={dismissMenu}
/>
);
};

View File

@@ -1,6 +1,6 @@
import { Dropdown } from "@fluentui/react";
import * as React from "react";
import { FunctionComponent } from "react";
import { SearchableDropdown } from "../../../Common/SearchableDropdown";
import { Subscription } from "../../../Contracts/DataModels";
interface Props {
@@ -15,24 +15,16 @@ export const SwitchSubscription: FunctionComponent<Props> = ({
selectedSubscription,
}: Props) => {
return (
<Dropdown
<SearchableDropdown<Subscription>
label="Subscription"
items={subscriptions}
selectedItem={selectedSubscription}
onSelect={(sub) => setSelectedSubscriptionId(sub.subscriptionId)}
getKey={(sub) => sub.subscriptionId}
getDisplayText={(sub) => sub.displayName}
placeholder="Select a Subscription"
filterPlaceholder="Filter subscriptions"
className="accountSwitchSubscriptionDropdown"
options={subscriptions?.map((sub) => {
return {
key: sub.subscriptionId,
text: sub.displayName,
data: sub,
};
})}
onChange={(_, option) => {
setSelectedSubscriptionId(String(option?.key));
}}
defaultSelectedKey={selectedSubscription?.subscriptionId}
placeholder={subscriptions && subscriptions.length === 0 ? "No Subscriptions Found" : "Select a Subscription"}
styles={{
callout: "accountSwitchSubscriptionDropdownMenu",
}}
/>
);
};

View File

@@ -2,7 +2,6 @@
// Some of the enums names are used in Fabric. Please do not rename them.
export enum Action {
CollapseTreeNode,
MetricsScenario,
CreateCollection, // Used in Fabric. Please do not rename.
CreateGlobalSecondaryIndex,
CreateDocument, // Used in Fabric. Please do not rename.

View File

@@ -316,6 +316,11 @@ body.isDarkMode {
background-color: transparent;
}
// High specificity override for any nested elements
* {
color: var(--colorNeutralForeground1);
}
// Ensure links maintain proper colors
.ms-Link {
color: var(--colorBrandForeground1);
@@ -433,6 +438,7 @@ body.isDarkMode {
button {
&:not(.ms-Button):not(.ms-IconButton) {
background-color: var(--colorNeutralBackground1);
color: var(--colorNeutralForeground1);
&:hover {

View File

@@ -12,7 +12,6 @@
--colorCompoundBrandStroke1: @SelectionColor;
--colorBrandForeground1: @LinkColor;
--colorPaletteRedForeground1: @ErrorColor;
--colorSuccessGreen: #107c10;
--overlayBackground: rgba(0, 0, 0, 0.4);
--colorBrandBackground: @SelectionColor;
--colorBrandBackgroundHover: @AccentMediumHigh;
@@ -33,7 +32,6 @@ body.isDarkMode {
--colorCompoundBrandStroke1: #4db6e8;
--colorBrandForeground1: #4db6e8;
--colorPaletteRedForeground1: #f25d5d;
--colorSuccessGreen: #107c10;
--overlayBackground: rgba(0, 0, 0, 0.4);
--colorBrandBackground: #0078d4;
--colorBrandBackgroundHover: #106ebe;

View File

@@ -1,163 +0,0 @@
# E2E Testing Setup for Cosmos DB Explorer
This document describes the pre-flight setup process for running end-to-end tests in the Cosmos DB Explorer project.
## Overview
The testing setup includes:
1. Azure CLI authentication
2. Subscription selection
3. Test account configuration
4. Token generation for NoSQL container operations
5. Browser storage state setup for Playwright
## Prerequisites
Before running tests, ensure you have:
- [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli) installed and updated
- PowerShell execution policy set to allow script execution
- Access to the Azure subscription: `074d02eb-4d74-486a-b299-b262264d1536`
- Access to the resource group: `bchoudhury-e2e-testing`
- Required Azure resources with prefix: `bchoudhury-e2e-`
## Automatic Setup (Recommended)
The setup runs automatically when you start Playwright tests:
```bash
npm run test:e2e
```
or
```bash
npx playwright test
```
The global setup script (`test/global-setup.ts`) will:
1. Check if you're logged in to Azure CLI
2. If not logged in, prompt for authentication
3. Set the correct subscription
4. Run the PowerShell configuration script
5. Generate required access tokens
6. Set up browser authentication state
## Manual Setup
If you need to run the setup independently:
```bash
npm run test:e2e:setup
```
or directly:
```bash
node setup-tests.js
```
## Manual Step-by-Step Setup
If you prefer to run each step manually:
### 1. Azure CLI Login
```bash
az login --scope https://management.core.windows.net//.default
```
### 2. Set Subscription
```bash
az account set --subscription "074d02eb-4d74-486a-b299-b262264d1536"
```
### 3. Configure Test Accounts
```powershell
.\test\scripts\set-test-accounts.ps1 -ResourceGroup "bchoudhury-e2e-testing" -Subscription "074d02eb-4d74-486a-b299-b262264d1536" -ResourcePrefix "bchoudhury-e2e-"
```
### 4. Set NoSQL Container Token
```bash
$ENV:NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://bchoudhury-e2e-sqlcontainercopyonly.documents.azure.com/.default" -o tsv --query accessToken)
```
## Environment Variables
The setup process configures these environment variables:
- `DE_TEST_RESOURCE_GROUP`: The Azure resource group for testing
- `DE_TEST_SUBSCRIPTION_ID`: The Azure subscription ID
- `DE_TEST_ACCOUNT_PREFIX`: The prefix used for test resources
- `NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN`: Access token for NoSQL operations
## Storage State
Browser authentication state is saved to:
```
playwright/.auth/user.json
```
This file is automatically generated and excluded from version control.
## Troubleshooting
### Common Issues
1. **Azure CLI not found**
- Install Azure CLI: https://docs.microsoft.com/en-us/cli/azure/install-azure-cli
2. **PowerShell execution policy error**
```powershell
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
```
3. **Authentication expired**
```bash
az logout
az login --scope https://management.core.windows.net//.default
```
4. **Resource not found**
- Verify you have access to the subscription and resource group
- Check that resources exist with the expected prefix
### Debug Mode
For verbose output during setup, you can run:
```bash
DEBUG=1 npm run test:e2e:setup
```
## File Structure
```
test/
├── global-setup.ts # Main global setup script
├── scripts/
│ ├── set-test-accounts.ps1 # PowerShell test account setup
│ ├── check-test-accounts.ps1
│ └── clean-test-accounts.ps1
└── ...
playwright/
└── .auth/
└── user.json # Browser storage state (generated)
setup-tests.js # Manual setup runner
playwright.config.ts # Playwright config with global setup
```
## Configuration
The setup is configured in:
- `playwright.config.ts`: Playwright configuration with global setup
- `test/global-setup.ts`: Main setup logic
- `setup-tests.js`: Manual setup runner
## Notes
- The setup only needs to run once per session
- Browser storage state persists between test runs
- Azure tokens are refreshed automatically by the Azure CLI
- The setup is idempotent - safe to run multiple times

View File

@@ -164,9 +164,6 @@ $ENV:NOSQL_TESTACCOUNT_TOKEN=az account get-access-token --scope "https://<accou
# NoSQL API (Readonly)
$ENV:NOSQL_READONLY_TESTACCOUNT_TOKEN=az account get-access-token --scope "https://<account name>.documents.azure.com/.default" -o tsv --query accessToken
# NoSQL API (Container Copy)
$ENV:NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN=az account get-access-token --scope "https://<account name>.documents.azure.com/.default" -o tsv --query accessToken
# Tables API
$ENV:TABLE_TESTACCOUNT_TOKEN=az account get-access-token --scope "https://<account name>.documents.azure.com/.default" -o tsv --query accessToken

View File

@@ -11,7 +11,7 @@ export interface TestNameOptions {
prefixed?: boolean;
}
export function generateUniqueName(baseName: string, options?: TestNameOptions): string {
export function generateUniqueName(baseName, options?: TestNameOptions): string {
const length = options?.length ?? 1;
const timestamp = options?.timestampped === undefined ? true : options.timestampped;
const prefixed = options?.prefixed === undefined ? true : options.prefixed;
@@ -40,7 +40,6 @@ export enum TestAccount {
Mongo32 = "Mongo32",
SQL = "SQL",
SQLReadOnly = "SQLReadOnly",
SQLContainerCopyOnly = "SQLContainerCopyOnly",
}
export const defaultAccounts: Record<TestAccount, string> = {
@@ -52,7 +51,6 @@ export const defaultAccounts: Record<TestAccount, string> = {
[TestAccount.Mongo32]: "github-e2etests-mongo32",
[TestAccount.SQL]: "github-e2etests-sql",
[TestAccount.SQLReadOnly]: "github-e2etests-sql-readonly",
[TestAccount.SQLContainerCopyOnly]: "github-e2etests-sql-containercopyonly",
};
export const resourceGroupName = process.env.DE_TEST_RESOURCE_GROUP ?? "de-e2e-tests";
@@ -79,14 +77,7 @@ export function getAccountName(accountType: TestAccount) {
);
}
type TestExplorerUrlOptions = {
iframeSrc?: string;
enablecontainercopy?: boolean;
};
export async function getTestExplorerUrl(accountType: TestAccount, options?: TestExplorerUrlOptions): Promise<string> {
const { iframeSrc, enablecontainercopy } = options ?? {};
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);
@@ -102,7 +93,6 @@ export async function getTestExplorerUrl(accountType: TestAccount, options?: Tes
const nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_TOKEN;
const nosqlReadOnlyRbacToken = process.env.NOSQL_READONLY_TESTACCOUNT_TOKEN;
const nosqlContainerCopyRbacToken = process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN;
const tableRbacToken = process.env.TABLE_TESTACCOUNT_TOKEN;
const gremlinRbacToken = process.env.GREMLIN_TESTACCOUNT_TOKEN;
const cassandraRbacToken = process.env.CASSANDRA_TESTACCOUNT_TOKEN;
@@ -118,16 +108,6 @@ export async function getTestExplorerUrl(accountType: TestAccount, options?: Tes
}
break;
case TestAccount.SQLContainerCopyOnly:
if (nosqlContainerCopyRbacToken) {
params.set("nosqlRbacToken", nosqlContainerCopyRbacToken);
params.set("enableaaddataplane", "true");
}
if (enablecontainercopy) {
params.set("enablecontainercopy", "true");
}
break;
case TestAccount.SQLReadOnly:
if (nosqlReadOnlyRbacToken) {
params.set("nosqlReadOnlyRbacToken", nosqlReadOnlyRbacToken);
@@ -185,39 +165,6 @@ export async function getTestExplorerUrl(accountType: TestAccount, options?: Tes
return `https://localhost:1234/testExplorer.html?${params.toString()}`;
}
type DropdownItemExpectations = {
ariaLabel?: string;
itemCount?: number;
};
type DropdownItemMatcher = {
name?: string;
position?: number;
};
export async function getDropdownItemByNameOrPosition(
frame: Frame,
matcher?: DropdownItemMatcher,
expectedOptions?: DropdownItemExpectations,
): Promise<Locator> {
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
if (expectedOptions?.ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(expectedOptions.ariaLabel);
}
if (expectedOptions?.itemCount) {
const items = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
await expect(items).toHaveCount(expectedOptions.itemCount);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
if (matcher?.name) {
return containerDropdownItems.filter({ hasText: matcher.name });
} else if (matcher?.position !== undefined) {
return containerDropdownItems.nth(matcher.position);
}
// Return first item if no matcher is provided
return containerDropdownItems.first();
}
/** Helper class that provides locator methods for TreeNode elements, on top of a Locator */
class TreeNode {
constructor(
@@ -378,9 +325,7 @@ type PanelOpenOptions = {
export enum CommandBarButton {
Save = "Save",
Execute = "Execute",
ExecuteQuery = "Execute Query",
UploadItem = "Upload Item",
}
/** Helper class that provides locator methods for DataExplorer components, on top of a Frame */
@@ -570,7 +515,7 @@ export class DataExplorer {
}
/** Waits for the Data Explorer app to load */
static async waitForExplorer(page: Page, options?: TestExplorerUrlOptions): Promise<DataExplorer> {
static async waitForExplorer(page: Page) {
const iframeElement = await page.getByTestId("DataExplorerFrame").elementHandle();
if (iframeElement === null) {
throw new Error("Explorer iframe not found");
@@ -582,127 +527,15 @@ export class DataExplorer {
throw new Error("Explorer frame not found");
}
if (!options?.enablecontainercopy) {
await explorerFrame?.getByTestId("DataExplorerRoot").waitFor();
}
await explorerFrame?.getByTestId("DataExplorerRoot").waitFor();
return new DataExplorer(explorerFrame);
}
/** Opens the Data Explorer app using the specified test account (and optionally, the provided IFRAME src url). */
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<DataExplorer> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc });
const url = await getTestExplorerUrl(testAccount, iframeSrc);
await page.goto(url);
return DataExplorer.waitForExplorer(page);
}
}
export async function waitForApiResponse(
page: Page,
urlPattern: string,
method?: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payloadValidator?: (payload: any) => boolean,
) {
try {
// Check if page is still valid before waiting
if (page.isClosed()) {
throw new Error(`Page is closed, cannot wait for API response: ${urlPattern}`);
}
return page.waitForResponse(
async (response) => {
const request = response.request();
if (!request.url().includes(urlPattern)) {
return false;
}
if (method && request.method() !== method) {
return false;
}
if (payloadValidator && (request.method() === "POST" || request.method() === "PUT")) {
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
return payloadValidator(payload);
} catch {
return false;
}
}
}
return true;
},
{ timeout: 60 * 1000 },
);
} catch (error) {
if (error instanceof Error && error.message.includes("Target page, context or browser has been closed")) {
console.warn("Page was closed while waiting for API response:", urlPattern);
throw new Error(`Page closed while waiting for API response: ${urlPattern}`);
}
throw error;
}
}
export async function interceptAndInspectApiRequest(
page: Page,
urlPattern: string,
method: string = "POST",
error: Error,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
errorValidator: (url?: string, payload?: any) => boolean,
): Promise<void> {
await page.route(
(url) => url.pathname.includes(urlPattern),
async (route, request) => {
if (request.method() !== method) {
await route.continue();
return;
}
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
if (errorValidator && errorValidator(request.url(), payload)) {
await route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({
code: "Conflict",
message: error.message,
}),
});
return;
}
} catch (err) {
if (err instanceof Error && err.message.includes("not allowed")) {
throw err;
}
}
}
await route.continue();
},
);
}
export class ContainerCopy {
constructor(
public frame: Frame,
public wrapper: Locator,
) {}
static async waitForContainerCopy(page: Page): Promise<ContainerCopy> {
const explorerFrame = await DataExplorer.waitForExplorer(page, { enablecontainercopy: true });
const containerCopyWrapper = explorerFrame.frame.locator("div#containerCopyWrapper");
return new ContainerCopy(explorerFrame.frame, containerCopyWrapper);
}
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<ContainerCopy> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true });
// console.log(`Navigating to URL: ${url}`);
await page.goto(url);
return ContainerCopy.waitForContainerCopy(page);
}
}

View File

@@ -1,145 +0,0 @@
const { chromium } = require('@playwright/test');
const { exec } = require('child_process');
const fs = require('fs').promises;
const path = require('path');
const { promisify } = require('util');
const execAsync = promisify(exec);
async function globalSetup(config) {
console.log('🚀 Starting global setup for Azure Cosmos DB Explorer tests...');
try {
// Step 1: Check if user is already logged in to Azure CLI
console.log('📋 Checking Azure CLI login status...');
try {
const { stdout: accountInfo } = await execAsync('az account show --output json');
const account = JSON.parse(accountInfo);
console.log(`✅ Already logged in as: ${account.user?.name} in subscription: ${account.name}`);
} catch (error) {
console.log('🔐 Not logged in to Azure CLI, initiating login...');
// Step 2: Login to Azure with specific scope
console.log('🔑 Logging in to Azure CLI...');
console.log('Please complete the authentication in your browser...');
await execAsync('az login --scope https://management.core.windows.net//.default');
console.log('✅ Azure CLI login completed');
}
// Step 3: Set the subscription
const targetSubscription = '074d02eb-4d74-486a-b299-b262264d1536';
console.log(`🔄 Setting subscription to: ${targetSubscription}`);
await execAsync(`az account set --subscription "${targetSubscription}"`);
console.log('✅ Subscription set successfully');
// Step 4: Run the PowerShell test account setup script
console.log('⚙️ Running test account setup script...');
const scriptPath = path.join(__dirname, 'scripts', 'set-test-accounts.ps1');
const resourceGroup = 'bchoudhury-e2e-testing';
const resourcePrefix = 'bchoudhury-e2e-';
// Try PowerShell 7 first, fallback to Windows PowerShell if not available
let psCommand = `pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "${scriptPath}" -ResourceGroup "${resourceGroup}" -Subscription "${targetSubscription}" -ResourcePrefix "${resourcePrefix}"`;
try {
const { stdout, stderr } = await execAsync(psCommand);
if (stdout) console.log('PowerShell Output:', stdout);
if (stderr) console.log('PowerShell Warnings:', stderr);
console.log('✅ Test account setup completed');
} catch (psError) {
console.log('🔄 PowerShell 7 not available, trying Windows PowerShell...');
// Fallback to Windows PowerShell with NoProfile to avoid module loading issues
const fallbackCommand = `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${scriptPath}" -ResourceGroup "${resourceGroup}" -Subscription "${targetSubscription}" -ResourcePrefix "${resourcePrefix}"`;
try {
const { stdout: fbStdout, stderr: fbStderr } = await execAsync(fallbackCommand);
if (fbStdout) console.log('PowerShell Output:', fbStdout);
if (fbStderr) console.log('PowerShell Warnings:', fbStderr);
console.log('✅ Test account setup completed');
} catch (fallbackError) {
console.error('❌ PowerShell script execution failed:', fallbackError.message);
console.warn('⚠️ The PowerShell script failed. This might be due to:');
console.warn(' 1. PowerShell version compatibility (script requires PowerShell 7+ for ?? operator)');
console.warn(' 2. Missing Azure PowerShell modules');
console.warn(' 3. Insufficient permissions or missing resources');
// Don't throw here as the script might have partial success
}
}
// Step 5: Set the NoSQL container copy test account token
console.log('🎟️ Setting up NoSQL container copy test account token...');
try {
const { stdout: tokenOutput } = await execAsync(
'az account get-access-token --scope "https://bchoudhury-e2e-sqlcontainercopyonly.documents.azure.com/.default" -o tsv --query accessToken'
);
const token = tokenOutput.trim();
if (token) {
process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN = token;
console.log('✅ NoSQL container copy token set successfully');
} else {
console.warn('⚠️ Failed to retrieve NoSQL container copy token');
}
} catch (tokenError) {
console.error('❌ Failed to set NoSQL token:', tokenError.message);
// Continue without throwing as this might not be critical for all tests
}
// Step 6: Create browser context and save storage state for authentication
console.log('🌐 Setting up browser authentication state...');
const browser = await chromium.launch();
const context = await browser.newContext({
ignoreHTTPSErrors: true,
});
const page = await context.newPage();
// Navigate to a login page or perform any authentication steps if needed
// For now, we'll just create an empty storage state that can be populated later
// Save the storage state
const storageStatePath = path.join(__dirname, '../playwright/.auth/user.json');
await fs.mkdir(path.dirname(storageStatePath), { recursive: true });
await context.storageState({ path: storageStatePath });
await browser.close();
console.log(`✅ Browser storage state saved to: ${storageStatePath}`);
// Step 7: Set environment variables that tests might need
console.log('📝 Setting up environment variables for tests...');
// Verify required environment variables are set
const requiredEnvVars = [
'DE_TEST_RESOURCE_GROUP',
'DE_TEST_SUBSCRIPTION_ID',
'DE_TEST_ACCOUNT_PREFIX'
];
const missingVars = requiredEnvVars.filter(varName => !process.env[varName]);
if (missingVars.length > 0) {
console.warn(`⚠️ Missing environment variables: ${missingVars.join(', ')}`);
console.warn('These should be set by the PowerShell script. Tests may fail.');
} else {
console.log('✅ All required environment variables are set');
}
console.log('🎉 Global setup completed successfully!');
console.log('\n📊 Setup Summary:');
console.log(` - Subscription: ${targetSubscription}`);
console.log(` - Resource Group: ${resourceGroup}`);
console.log(` - Resource Prefix: ${resourcePrefix}`);
console.log(` - Storage State: ${storageStatePath}`);
} catch (error) {
console.error('❌ Global setup failed:', error.message);
console.error('Stack trace:', error.stack);
throw error;
}
}
module.exports = globalSetup;

View File

@@ -1,135 +0,0 @@
import { type FullConfig } from '@playwright/test';
import { exec } from 'child_process';
import path from 'path';
import { promisify } from 'util';
const execAsync = promisify(exec);
export default async function globalSetup(
_config?: FullConfig
): Promise<void> {
console.log('🚀 Starting global setup for Azure Cosmos DB Explorer tests...');
try {
// ------------------------------------------------------------------
// Step 1: Azure CLI login
// ------------------------------------------------------------------
console.log('📋 Checking Azure CLI login status...');
try {
const { stdout } = await execAsync('az account show --output json');
const account = JSON.parse(stdout);
console.log(
`✅ Already logged in as: ${account.user?.name} in subscription: ${account.name}`
);
} catch {
console.log('🔐 Not logged in to Azure CLI, initiating login...');
await execAsync(
'az login --scope https://management.core.windows.net//.default'
);
console.log('✅ Azure CLI login completed');
}
// ------------------------------------------------------------------
// Step 2: Set subscription
// ------------------------------------------------------------------
const targetSubscription = '074d02eb-4d74-486a-b299-b262264d1536';
console.log(`🔄 Setting subscription to: ${targetSubscription}`);
await execAsync(
`az account set --subscription "${targetSubscription}"`
);
// ------------------------------------------------------------------
// Step 3: Run PowerShell test account setup (DOT-SOURCED)
// ------------------------------------------------------------------
console.log('⚙️ Running test account setup script...');
const scriptPath = path.join(
__dirname,
'scripts',
'set-test-accounts.ps1'
);
const resourceGroup = 'bchoudhury-e2e-testing';
const resourcePrefix = 'bchoudhury-e2e-';
// IMPORTANT:
// - Dot-source the PS1
// - Print env vars BEFORE PowerShell exits
const psScript = `
. "${scriptPath}" `
+ `-ResourceGroup "${resourceGroup}" `
+ `-Subscription "${targetSubscription}" `
+ `-ResourcePrefix "${resourcePrefix}"
Get-ChildItem Env:DE_TEST_* | ForEach-Object {
Write-Output "$($_.Name)=$($_.Value)"
}
`;
// PowerShell requires UTF-16LE encoding for -EncodedCommand
const encodedCommand = Buffer.from(psScript, 'utf16le').toString('base64');
const { stdout: psStdout, stderr: psStderr } = await execAsync(
`pwsh -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}`
);
if (psStderr) {
console.log('PowerShell warnings:', psStderr);
}
// ------------------------------------------------------------------
// Step 3a: Import env vars from PowerShell
// ------------------------------------------------------------------
psStdout
.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line.startsWith('DE_TEST_'))
.forEach(line => {
const [key, ...rest] = line.split('=');
process.env[key] = rest.join('=');
console.log(`✅ Imported env var from PS: ${key}`);
});
// ------------------------------------------------------------------
// Step 4: NoSQL access token
// ------------------------------------------------------------------
console.log('🎟️ Setting up NoSQL container copy test account token...');
try {
const { stdout } = await execAsync(
'az account get-access-token --scope "https://bchoudhury-e2e-sqlcontainercopyonly.documents.azure.com/.default" -o tsv --query accessToken'
);
const token = stdout.trim();
if (token) {
process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN = token;
console.log('✅ NoSQL container copy token set successfully');
}
} catch (err: any) {
console.error('❌ Failed to set NoSQL token:', err.message);
}
// Browser authentication not needed - using API tokens directly
// ------------------------------------------------------------------
// Step 6: Validate env vars
// ------------------------------------------------------------------
console.log('📝 Validating environment variables...');
const requiredEnvVars = [
'DE_TEST_RESOURCE_GROUP',
'DE_TEST_SUBSCRIPTION_ID',
'DE_TEST_ACCOUNT_PREFIX',
];
const missing = requiredEnvVars.filter(v => !process.env[v]);
if (missing.length > 0) {
console.warn(`⚠️ Missing environment variables: ${missing.join(', ')}`);
} else {
console.log('✅ All required environment variables are set');
}
console.log('🎉 Global setup completed successfully!');
} catch (error: any) {
console.error('❌ Global setup failed:', error.message);
console.error(error.stack);
throw error;
}
}

View File

@@ -18,10 +18,7 @@ if (-not $Subscription) {
$Subscription = $currentSubscription.Id
}
$AzSubscription = (Get-AzSubscription -SubscriptionId $Subscription -ErrorAction SilentlyContinue | Select-Object -First 1)
if (-not $AzSubscription) {
$AzSubscription = (Get-AzSubscription -SubscriptionName $Subscription -ErrorAction SilentlyContinue | Select-Object -First 1)
}
$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."
}

View File

@@ -1,7 +0,0 @@
import { test, expect } from '@playwright/test';
test.describe('Test group', () => {
test('seed', async ({ page }) => {
// generate code here.
});
});

View File

@@ -1,51 +0,0 @@
import { expect, test } from '@playwright/test';
test.describe('Azure Setup Verification', () => {
test('should have required environment variables set', async ({ page }) => {
// Verify that the global setup has properly configured the environment
const requiredEnvVars = [
'DE_TEST_RESOURCE_GROUP',
'DE_TEST_SUBSCRIPTION_ID',
'DE_TEST_ACCOUNT_PREFIX'
];
for (const envVar of requiredEnvVars) {
expect(process.env[envVar], `Environment variable ${envVar} should be set by global setup`).toBeDefined();
expect(process.env[envVar], `Environment variable ${envVar} should not be empty`).not.toBe('');
}
console.log('✅ Environment Variables:');
console.log(` DE_TEST_RESOURCE_GROUP: ${process.env.DE_TEST_RESOURCE_GROUP}`);
console.log(` DE_TEST_SUBSCRIPTION_ID: ${process.env.DE_TEST_SUBSCRIPTION_ID}`);
console.log(` DE_TEST_ACCOUNT_PREFIX: ${process.env.DE_TEST_ACCOUNT_PREFIX}`);
if (process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN) {
console.log(` NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN: [SET]`);
} else {
console.log(` NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN: [NOT SET]`);
}
});
/* test('should be able to navigate to the application', async ({ page }) => {
// This assumes the web server is running on https://127.0.0.1:1234
// as configured in playwright.config.ts
try {
await page.goto('https://127.0.0.1:1234');
// Wait for the page to load
await page.waitForTimeout(2000);
// Check if the page loaded successfully
const title = await page.title();
console.log(`Page title: ${title}`);
// This is a basic check - you might want to check for specific elements
// that indicate the Cosmos DB Explorer has loaded correctly
expect(title).toBeTruthy();
} catch (error) {
console.log('Note: Application server might not be running. Start it with: npm run start');
throw error;
}
}); */
});

View File

@@ -1,505 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { expect, Frame, Locator, Page, test } from "@playwright/test";
import { set } from "lodash";
import { truncateName } from "../../src/Explorer/ContainerCopy/CopyJobUtils";
import {
ContainerCopy,
getAccountName,
getDropdownItemByNameOrPosition,
interceptAndInspectApiRequest,
TestAccount,
waitForApiResponse,
} from "../fx";
import { createMultipleTestContainers } from "../testData";
let page: Page;
let wrapper: Locator = null!;
let panel: Locator = null!;
let frame: Frame = null!;
let expectedCopyJobNameInitial: string = null!;
let expectedJobName: string = "";
let targetAccountName: string = "";
let expectedSourceAccountName: string = "";
let expectedSubscriptionName: string = "";
const VISIBLE_TIMEOUT_MS = 30 * 1000;
test.describe.configure({ mode: "serial" });
test.describe("Container Copy", () => {
test.beforeAll("Container Copy - Before All", async ({ browser }) => {
await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 3 });
page = await browser.newPage();
({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly));
expectedJobName = `test_job_${Date.now()}`;
targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
});
test.afterEach("Container Copy - After Each", async () => {
await page.unroute(/.*/, (route) => route.continue());
});
test("Loading and verifying the content of the page", async () => {
expect(wrapper).not.toBeNull();
await expect(wrapper.getByTestId("CommandBar/Button:Create Copy Job")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
await expect(wrapper.getByTestId("CommandBar/Button:Refresh")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
await expect(wrapper.getByTestId("CommandBar/Button:Feedback")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
});
test("Successfully create a copy job for offline migration", async () => {
expect(wrapper).not.toBeNull();
// Loading and verifying subscription & account dropdown
const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
await createCopyJobButton.click();
panel = frame.getByTestId("Panel:Create copy job");
await expect(panel).toBeVisible();
await page.waitForTimeout(10 * 1000);
const subscriptionDropdown = panel.getByTestId("subscription-dropdown");
const expectedAccountName = targetAccountName;
expectedSubscriptionName = await subscriptionDropdown.locator("span.ms-Dropdown-title").innerText();
await subscriptionDropdown.click();
const subscriptionItem = await getDropdownItemByNameOrPosition(
frame,
{ name: expectedSubscriptionName },
{ ariaLabel: "Subscription" },
);
await subscriptionItem.click();
// Load account dropdown based on selected subscription
const accountDropdown = panel.getByTestId("account-dropdown");
await expect(accountDropdown).toHaveText(new RegExp(expectedAccountName));
await accountDropdown.click();
const accountItem = await getDropdownItemByNameOrPosition(
frame,
{ name: expectedAccountName },
{ ariaLabel: "Account" },
);
await accountItem.click();
// Verifying online or offline migration functionality
/**
* This test verifies the functionality of the migration type radio that toggles between
* online and offline container copy modes. It ensures that:
* 1. When online mode is selected, the user is directed to a permissions screen
* 2. When offline mode is selected, the user bypasses the permissions screen
* 3. The UI correctly reflects the selected migration type throughout the workflow
*/
const migrationTypeContainer = panel.getByTestId("migration-type");
const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i });
await onlineCopyRadioButton.click({ force: true });
await expect(migrationTypeContainer.getByTestId("migration-type-description-online")).toBeVisible();
await panel.getByRole("button", { name: "Next" }).click();
await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).toBeVisible();
await expect(panel.getByText("Online container copy", { exact: true })).toBeVisible();
await panel.getByRole("button", { name: "Previous" }).click();
const offlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Offline mode/i });
await offlineCopyRadioButton.click({ force: true });
await expect(migrationTypeContainer.getByTestId("migration-type-description-offline")).toBeVisible();
await panel.getByRole("button", { name: "Next" }).click();
await expect(panel.getByTestId("Panel:SelectSourceAndTargetContainers")).toBeVisible();
await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).not.toBeVisible();
// Verifying source and target container selection
const sourceContainerDropdown = panel.getByTestId("source-containerDropdown");
expect(sourceContainerDropdown).toBeVisible();
await expect(sourceContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/);
const sourceDatabaseDropdown = panel.getByTestId("source-databaseDropdown");
await sourceDatabaseDropdown.click();
const sourceDbDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Database" },
);
await sourceDbDropdownItem.click();
await expect(sourceContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await sourceContainerDropdown.click();
const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Container" },
);
await sourceContainerDropdownItem.click();
const targetContainerDropdown = panel.getByTestId("target-containerDropdown");
expect(targetContainerDropdown).toBeVisible();
await expect(targetContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/);
const targetDatabaseDropdown = panel.getByTestId("target-databaseDropdown");
await targetDatabaseDropdown.click();
const targetDbDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Database" },
);
await targetDbDropdownItem.click();
await expect(targetContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await targetContainerDropdown.click();
const targetContainerDropdownItem1 = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Container" },
);
await targetContainerDropdownItem1.click();
await panel.getByRole("button", { name: "Next" }).click();
const errorContainer = panel.getByTestId("Panel:ErrorContainer");
await expect(errorContainer).toBeVisible();
await expect(errorContainer).toHaveText(/Source and destination containers cannot be the same/i);
// Reselect target container to be different from source container
await targetContainerDropdown.click();
const targetContainerDropdownItem2 = await getDropdownItemByNameOrPosition(
frame,
{ position: 1 },
{ ariaLabel: "Container" },
);
await targetContainerDropdownItem2.click();
const selectedSourceDatabase = await sourceDatabaseDropdown.innerText();
const selectedSourceContainer = await sourceContainerDropdown.innerText();
const selectedTargetDatabase = await targetDatabaseDropdown.innerText();
const selectedTargetContainer = await targetContainerDropdown.innerText();
expectedCopyJobNameInitial = `${truncateName(selectedSourceDatabase)}.${truncateName(
selectedSourceContainer,
)}_${truncateName(selectedTargetDatabase)}.${truncateName(selectedTargetContainer)}`;
await panel.getByRole("button", { name: "Next" }).click();
await expect(errorContainer).not.toBeVisible();
await expect(panel.getByTestId("Panel:PreviewCopyJob")).toBeVisible();
// Verifying the preview of the copy job
const previewContainer = panel.getByTestId("Panel:PreviewCopyJob");
await expect(previewContainer).toBeVisible();
await expect(previewContainer.getByTestId("source-subscription-name")).toHaveText(expectedSubscriptionName);
await expect(previewContainer.getByTestId("source-account-name")).toHaveText(expectedAccountName);
const jobNameInput = previewContainer.getByTestId("job-name-textfield");
await expect(jobNameInput).toHaveValue(new RegExp(expectedCopyJobNameInitial));
const primaryBtn = panel.getByRole("button", { name: "Copy", exact: true });
await expect(primaryBtn).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await jobNameInput.fill("test job name");
await expect(primaryBtn).toHaveClass(/(^|\s)is-disabled(\s|$)/);
// Testing API request interception with duplicate job name
const duplicateJobName = "test-job-name-1";
await jobNameInput.fill(duplicateJobName);
const copyButton = panel.getByRole("button", { name: "Copy", exact: true });
const expectedErrorMessage = `Duplicate job name '${duplicateJobName}'`;
await interceptAndInspectApiRequest(
page,
`${expectedAccountName}/dataTransferJobs/${duplicateJobName}`,
"PUT",
new Error(expectedErrorMessage),
(url?: string) => url?.includes(duplicateJobName) ?? false,
);
let errorThrown = false;
try {
await copyButton.click();
await page.waitForTimeout(2000);
} catch (error: any) {
errorThrown = true;
expect(error.message).toContain("not allowed");
}
if (!errorThrown) {
const errorContainer = panel.getByTestId("Panel:ErrorContainer");
await expect(errorContainer).toBeVisible();
await expect(errorContainer).toHaveText(new RegExp(expectedErrorMessage, "i"));
}
await expect(panel).toBeVisible();
// Testing API request success with valid job name and verifying copy job creation
const validJobName = expectedJobName;
const copyJobCreationPromise = waitForApiResponse(
page,
`${expectedAccountName}/dataTransferJobs/${validJobName}`,
"PUT",
);
await jobNameInput.fill(validJobName);
await expect(copyButton).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await copyButton.click();
const response = await copyJobCreationPromise;
expect(response.ok()).toBe(true);
await expect(panel).not.toBeVisible({ timeout: 10000 });
const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page");
await jobsListContainer.waitFor({ state: "visible" });
const jobItem = jobsListContainer.getByText(validJobName);
await jobItem.waitFor({ state: "visible" });
await expect(jobItem).toBeVisible();
});
test("Verify Online or Offline Container Copy Permissions Panel", async () => {
expect(wrapper).not.toBeNull();
// Opening the Create Copy Job panel again to verify initial state
const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
await createCopyJobButton.click();
panel = frame.getByTestId("Panel:Create copy job");
await expect(panel).toBeVisible();
await expect(panel.getByRole("heading", { name: "Create copy job" })).toBeVisible();
// select different account dropdown
const accountDropdown = panel.getByTestId("account-dropdown");
await accountDropdown.click();
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual("Account");
const allDropdownItems = await dropdownItemsWrapper.locator(`button.ms-Dropdown-item[role='option']`).all();
const filteredItems = [];
for (const item of allDropdownItems) {
const testContent = (await item.textContent()) ?? "";
if (testContent.trim() !== targetAccountName.trim()) {
filteredItems.push(item);
}
}
if (filteredItems.length > 0) {
const firstDropdownItem = filteredItems[0];
expectedSourceAccountName = (await firstDropdownItem.textContent()) ?? "";
await firstDropdownItem.click();
} else {
throw new Error("No dropdown items available after filtering");
}
const migrationTypeContainer = panel.getByTestId("migration-type");
const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i });
await onlineCopyRadioButton.click({ force: true });
await panel.getByRole("button", { name: "Next" }).click();
// Verifying Assign Permissions panel for online copy
const permissionScreen = panel.getByTestId("Panel:AssignPermissionsContainer");
await expect(permissionScreen).toBeVisible();
await expect(permissionScreen.getByText("Online container copy", { exact: true })).toBeVisible();
await expect(permissionScreen.getByText("Cross-account container copy", { exact: true })).toBeVisible();
// Verify Point-in-Time Restore timer and refresh button workflow
await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}**`, async (route) => {
const mockData = {
identity: {
type: "SystemAssigned",
principalId: "00-11-22-33",
},
properties: {
defaultIdentity: "SystemAssignedIdentity",
backupPolicy: {
type: "Continuous",
},
capabilities: [{ name: "EnableOnlineContainerCopy" }],
},
};
if (route.request().method() === "GET") {
const response = await route.fetch();
const actualData = await response.json();
const mergedData = { ...actualData };
set(mergedData, "identity", mockData.identity);
set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity);
set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy);
set(mergedData, "properties.capabilities", mockData.properties.capabilities);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mergedData),
});
} else {
await route.continue();
}
});
await expect(permissionScreen).toBeVisible();
const expandedOnlineAccordionHeader = permissionScreen
.getByTestId("permission-group-container-onlineConfigs")
.locator("button[aria-expanded='true']");
await expect(expandedOnlineAccordionHeader).toBeVisible();
const accordionItem = expandedOnlineAccordionHeader
.locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]")
.first();
const accordionPanel = accordionItem
.locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']")
.first();
await page.clock.install({ time: new Date("2024-01-01T10:00:00Z") });
const pitrBtn = accordionPanel.getByTestId("pointInTimeRestore:PrimaryBtn");
await expect(pitrBtn).toBeVisible();
await pitrBtn.click();
page.context().on("page", async (newPage) => {
const expectedUrlEndPattern = new RegExp(
`/providers/Microsoft.(DocumentDB|DocumentDb)/databaseAccounts/${expectedSourceAccountName}/backupRestore`,
);
expect(newPage.url()).toMatch(expectedUrlEndPattern);
await newPage.close();
});
const loadingOverlay = frame.locator("[data-test='loading-overlay']");
await expect(loadingOverlay).toBeVisible();
const refreshBtn = accordionPanel.getByTestId("pointInTimeRestore:RefreshBtn");
await expect(refreshBtn).not.toBeVisible();
// Fast forward time by 11 minutes (11 * 60 * 1000ms = 660000ms)
await page.clock.fastForward(11 * 60 * 1000);
await expect(refreshBtn).toBeVisible();
await expect(pitrBtn).not.toBeVisible();
// Veify Popover & Loading Overlay on permission screen with API mocks and accordion interactions
await page.route(
`**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/sqlRoleAssignments*`,
async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
value: [
{
principalId: "00-11-22-33",
roleDefinitionId: `Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/77-88-99`,
},
],
}),
});
},
);
await page.route("**/Microsoft.DocumentDB/databaseAccounts/*/77-88-99**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
value: [
{
name: "00000000-0000-0000-0000-000000000001",
},
],
}),
});
});
await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${targetAccountName}**`, async (route) => {
const mockData = {
identity: {
type: "SystemAssigned",
principalId: "00-11-22-33",
},
properties: {
defaultIdentity: "SystemAssignedIdentity",
backupPolicy: {
type: "Continuous",
},
capabilities: [{ name: "EnableOnlineContainerCopy" }],
},
};
if (route.request().method() === "PATCH") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ status: "Succeeded" }),
});
} else if (route.request().method() === "GET") {
// Get the actual response and merge with mock data
const response = await route.fetch();
const actualData = await response.json();
const mergedData = { ...actualData };
set(mergedData, "identity", mockData.identity);
set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity);
set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy);
set(mergedData, "properties.capabilities", mockData.properties.capabilities);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mergedData),
});
} else {
await route.continue();
}
});
await expect(permissionScreen).toBeVisible();
const expandedCrossAccordionHeader = permissionScreen
.getByTestId("permission-group-container-crossAccountConfigs")
.locator("button[aria-expanded='true']");
await expect(expandedCrossAccordionHeader).toBeVisible();
const crossAccordionItem = expandedCrossAccordionHeader
.locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]")
.first();
const crossAccordionPanel = crossAccordionItem
.locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']")
.first();
const toggleButton = crossAccordionPanel.getByTestId("btn-toggle");
await expect(toggleButton).toBeVisible();
await toggleButton.click();
const popover = frame.locator("[data-test='popover-container']");
await expect(popover).toBeVisible();
const yesButton = popover.getByRole("button", { name: /Yes/i });
const noButton = popover.getByRole("button", { name: /No/i });
await expect(yesButton).toBeVisible();
await expect(noButton).toBeVisible();
await yesButton.click();
await expect(loadingOverlay).toBeVisible();
await expect(loadingOverlay).toBeHidden({ timeout: 10 * 1000 });
await expect(popover).toBeHidden({ timeout: 10 * 1000 });
await panel.getByRole("button", { name: "Cancel" }).click();
});
test.afterAll("Container Copy - After All", async () => {
await page.unroute(/.*/, (route) => route.continue());
await page.close();
});
});

Some files were not shown because too many files have changed in this diff Show More