diff --git a/images/ContainerCopy/copy-jobs.svg b/images/ContainerCopy/copy-jobs.svg new file mode 100644 index 000000000..2f691ed76 --- /dev/null +++ b/images/ContainerCopy/copy-jobs.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/Common/DatabaseAccountUtility.ts b/src/Common/DatabaseAccountUtility.ts index 50ec0064a..af3e1b699 100644 --- a/src/Common/DatabaseAccountUtility.ts +++ b/src/Common/DatabaseAccountUtility.ts @@ -1,7 +1,7 @@ import { TagNames, WorkloadType } from "Common/Constants"; import { Tags } from "Contracts/DataModels"; import { isFabric } from "Platform/Fabric/FabricUtil"; -import { userContext } from "../UserContext"; +import { ApiType, userContext } from "../UserContext"; function isVirtualNetworkFilterEnabled() { return userContext.databaseAccount?.properties?.isVirtualNetworkFilterEnabled; @@ -33,3 +33,33 @@ export function isGlobalSecondaryIndexEnabled(): boolean { !isFabric() && userContext.apiType === "SQL" && userContext.databaseAccount?.properties?.enableMaterializedViews ); } + +export const getDatabaseEndpoint = (apiType: ApiType): string => { + switch (apiType) { + case "Mongo": + return "mongodbDatabases"; + case "Cassandra": + return "cassandraKeyspaces"; + case "Gremlin": + return "gremlinDatabases"; + case "Tables": + return "tables"; + default: + case "SQL": + return "sqlDatabases"; + } +}; + +export const getCollectionEndpoint = (apiType: ApiType): string => { + switch (apiType) { + case "Mongo": + return "collections"; + case "Cassandra": + return "tables"; + case "Gremlin": + return "graphs"; + default: + case "SQL": + return "containers"; + } +}; \ No newline at end of file diff --git a/src/Contracts/DataModels.ts b/src/Contracts/DataModels.ts index 7dfe33bbb..caf4b32e3 100644 --- a/src/Contracts/DataModels.ts +++ b/src/Contracts/DataModels.ts @@ -101,6 +101,24 @@ export interface Subscription { authorizationSource?: string; } +export interface DatabaseModel extends ArmEntity { + properties: DatabaseGetProperties; +} + +export interface DatabaseGetProperties { + resource: DatabaseResource & ExtendedResourceProperties; +} +export interface DatabaseResource { + id: string; +} + +export interface ExtendedResourceProperties { + readonly _rid?: string; + readonly _self?: string; + readonly _ts?: number; + readonly _etag?: string; +} + export interface SubscriptionPolicies { locationPlacementId: string; quotaId: string; @@ -179,7 +197,7 @@ export interface Database extends Resource { collections?: Collection[]; } -export interface DocumentId extends Resource {} +export interface DocumentId extends Resource { } export interface ConflictId extends Resource { resourceId?: string; diff --git a/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx b/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx new file mode 100644 index 000000000..41fb0cf92 --- /dev/null +++ b/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { useSidePanel } from "../../../hooks/useSidePanel"; +import ContainerCopyMessages from "../ContainerCopyMessages"; +import CreateCopyJobScreensProvider from "../CreateCopyJob/Screens/CreateCopyJobScreensProvider"; +import { CopyJobContextState } from "../Types"; + +export const openCreateCopyJobPanel = () => { + const sidePanelState = useSidePanel.getState() + sidePanelState.setPanelHasConsole(false); + sidePanelState.openSidePanel( + ContainerCopyMessages.createCopyJobPanelTitle, + , + "600px" + ); +} + +export const submitCreateCopyJob = (state: CopyJobContextState) => { + console.log("Submitting create copy job with state:", state); +}; \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx b/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx new file mode 100644 index 000000000..724cecd15 --- /dev/null +++ b/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx @@ -0,0 +1,31 @@ +import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react"; +import React from "react"; +import { StyleConstants } from "../../../Common/StyleConstants"; +import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; +import * as CommandBarUtil from "../../Menus/CommandBar/CommandBarUtil"; +import { ContainerCopyProps } from "../Types"; +import { getCommandBarButtons } from "./Utils"; + +const backgroundColor = StyleConstants.BaseLight; +const rootStyle = { + root: { + backgroundColor: backgroundColor, + }, +}; + +const CopyJobCommandBar: React.FC = ({ container }) => { + const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(container); + const controlButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(commandBarItems, backgroundColor); + + return ( +
+ +
+ ); +} + +export default CopyJobCommandBar; \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/CommandBar/Utils.ts b/src/Explorer/ContainerCopy/CommandBar/Utils.ts new file mode 100644 index 000000000..faaec7779 --- /dev/null +++ b/src/Explorer/ContainerCopy/CommandBar/Utils.ts @@ -0,0 +1,56 @@ +import AddIcon from "../../../../images/Add.svg"; +import FeedbackIcon from "../../../../images/Feedback-Command.svg"; +import RefreshIcon from "../../../../images/refresh-cosmos.svg"; +import { configContext, Platform } from "../../../ConfigContext"; +import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; +import Explorer from "../../Explorer"; +import * as Actions from "../Actions/CopyJobActions"; +import ContainerCopyMessages from "../ContainerCopyMessages"; +import { CopyJobCommandBarBtnType } from "../Types"; + +function getCopyJobBtns(): CopyJobCommandBarBtnType[] { + const buttons: CopyJobCommandBarBtnType[] = [ + { + key: "createCopyJob", + iconSrc: AddIcon, + label: ContainerCopyMessages.createCopyJobButtonLabel, + ariaLabel: ContainerCopyMessages.createCopyJobButtonAriaLabel, + onClick: Actions.openCreateCopyJobPanel, + }, + { + key: "refresh", + iconSrc: RefreshIcon, + label: ContainerCopyMessages.refreshButtonLabel, + ariaLabel: ContainerCopyMessages.refreshButtonAriaLabel, + onClick: () => { }, + }, + ]; + if (configContext.platform === Platform.Portal) { + buttons.push({ + key: "feedback", + iconSrc: FeedbackIcon, + label: ContainerCopyMessages.feedbackButtonLabel, + ariaLabel: ContainerCopyMessages.feedbackButtonAriaLabel, + onClick: () => { }, + }); + } + return buttons; +} + +function btnMapper(config: CopyJobCommandBarBtnType): CommandButtonComponentProps { + return { + iconSrc: config.iconSrc, + iconAlt: config.label, + onCommandClick: config.onClick, + commandButtonLabel: undefined as string | undefined, + ariaLabel: config.ariaLabel, + tooltipText: config.label, + hasPopup: false, + disabled: config.disabled ?? false, + }; +} + +export function getCommandBarButtons(_container: Explorer): CommandButtonComponentProps[] { + return getCopyJobBtns().map(btnMapper); +} + diff --git a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts new file mode 100644 index 000000000..a72f9abcc --- /dev/null +++ b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts @@ -0,0 +1,42 @@ +export default { + // Copy Job Command Bar + feedbackButtonLabel: "Feedback", + feedbackButtonAriaLabel: "Provide feedback on copy jobs", + refreshButtonLabel: "Refresh", + refreshButtonAriaLabel: "Refresh copy jobs", + createCopyJobButtonLabel: "Create Copy Job", + createCopyJobButtonAriaLabel: "Create a new container copy job", + + // No Copy Jobs Found + noCopyJobsTitle: "No copy jobs to show", + createCopyJobButtonText: "Create a container copy job", + + // Create Copy Job Panel + createCopyJobPanelTitle: "Copy container", + + // Select Account Screen + selectAccountDescription: "Please select a source account from which to copy.", + subscriptionDropdownLabel: "Subscription", + subscriptionDropdownPlaceholder: "Select a subscription", + sourceAccountDropdownLabel: "Account", + sourceAccountDropdownPlaceholder: "Select an account", + migrationTypeCheckboxLabel: "Copy container in offline mode", + + // Select Source and Target Containers Screen + selectSourceAndTargetContainersDescription: "Please select a source container and a destination container to copy to.", + sourceContainerSubHeading: "Source container", + targetContainerSubHeading: "Destination container", + databaseDropdownLabel: "Database", + databaseDropdownPlaceholder: "Select a database", + containerDropdownLabel: "Container", + containerDropdownPlaceholder: "Select a container", + + // Preview and Create Screen + jobNameLabel: "Job name", + sourceSubscriptionLabel: "Source subscription", + sourceAccountLabel: "Source account", + sourceDatabaseLabel: "Source database", + sourceContainerLabel: "Source container", + targetDatabaseLabel: "Destination database", + targetContainerLabel: "Destination container", +} \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx b/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx new file mode 100644 index 000000000..2a8eac983 --- /dev/null +++ b/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { userContext } from "UserContext"; +import { useAADAuth } from "../../../hooks/useAADAuth"; +import { useConfig } from "../../../hooks/useConfig"; +import { CopyJobContextProviderType, CopyJobContextState, CopyJobFlowType } from "../Types"; + +export const CopyJobContext = React.createContext(null); +export const useCopyJobContext = (): CopyJobContextProviderType => { + const context = React.useContext(CopyJobContext); + if (!context) { + throw new Error("useCopyJobContext must be used within a CopyJobContextProvider"); + } + return context; +} + +interface CopyJobContextProviderProps { + children: React.ReactNode; +} + +const getInitialCopyJobState = (): CopyJobContextState => { + return { + jobName: "", + migrationType: "offline", + source: { + subscription: null, + account: null, + databaseId: "", + containerId: "", + }, + target: { + subscriptionId: userContext.subscriptionId || "", + account: userContext.databaseAccount || null, + databaseId: "", + containerId: "", + }, + } +} + +const CopyJobContextProvider: React.FC = (props) => { + const config = useConfig(); + const { isLoggedIn, armToken } = useAADAuth(config); + const [copyJobState, setCopyJobState] = React.useState(getInitialCopyJobState()); + const [flow, setFlow] = React.useState(null); + + if (!isLoggedIn || !armToken) { + // Add a shimmer or loader here + return null; + } + + const resetCopyJobState = () => { + setCopyJobState(getInitialCopyJobState()); + } + + return ( + + {props.children} + + ); +} + +export default CopyJobContextProvider; \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow.tsx new file mode 100644 index 000000000..02f5569d2 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow.tsx @@ -0,0 +1,27 @@ +import { Stack } from "@fluentui/react"; +import React from "react"; + +interface FieldRowProps { + label?: string; + children: React.ReactNode; + labelClassName?: string; +} + +const FieldRow: React.FC = ({ label = "", children, labelClassName = "" }) => { + return ( + + { + label && ( + + + + ) + } + + {children} + + + ); +}; + +export default FieldRow; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.tsx new file mode 100644 index 000000000..776b2ece4 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.tsx @@ -0,0 +1,28 @@ +import { DefaultButton, PrimaryButton, Stack } from "@fluentui/react"; +import React from "react"; + +type NavigationControlsProps = { + primaryBtnText: string; + onPrimary: () => void; + onPrevious: () => void; + onCancel: () => void; + isPrimaryDisabled: boolean; + isPreviousDisabled: boolean; +}; + +const NavigationControls: React.FC = ({ + primaryBtnText, + onPrimary, + onPrevious, + onCancel, + isPrimaryDisabled, + isPreviousDisabled, +}) => ( + + + + + +); + +export default React.memo(NavigationControls); \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx new file mode 100644 index 000000000..8293d282d --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx @@ -0,0 +1,36 @@ +import { Stack } from "@fluentui/react"; +import React from "react"; +import { useCopyJobContext } from "../../Context/CopyJobContext"; +import { useCopyJobNavigation } from "../Utils/useCopyJobNavigation"; +import NavigationControls from "./Components/NavigationControls"; + +const CreateCopyJobScreens: React.FC = () => { + const { copyJobState } = useCopyJobContext(); + const { + currentScreen, + isPrimaryDisabled, + isPreviousDisabled, + handlePrimary, + handlePrevious, + handleCancel, + primaryBtnText + } = useCopyJobNavigation(copyJobState); + + return ( + + {currentScreen?.component} + + + + + ); +}; + +export default CreateCopyJobScreens; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreensProvider.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreensProvider.tsx new file mode 100644 index 000000000..ecf326226 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreensProvider.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import CopyJobContextProvider from "../../Context/CopyJobContext"; +import CreateCopyJobScreens from "./CreateCopyJobScreens"; + +const CreateCopyJobScreensProvider = () => { + return ( + + + + ); +}; + +export default CreateCopyJobScreensProvider; \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/PreviewCopyJobUtils.ts b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/PreviewCopyJobUtils.ts new file mode 100644 index 000000000..d01ed766f --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/PreviewCopyJobUtils.ts @@ -0,0 +1,31 @@ +import { IColumn } from "@fluentui/react"; +import ContainerCopyMessages from "../../../../ContainerCopyMessages"; + +export const getPreviewCopyJobDetailsListColumns = function (): IColumn[] { + return [ + { + key: 'sourcedbname', + name: ContainerCopyMessages.sourceDatabaseLabel, + fieldName: 'sourceDatabaseName', + minWidth: 100 + }, + { + key: 'sourcecolname', + name: ContainerCopyMessages.sourceContainerLabel, + fieldName: 'sourceContainerName', + minWidth: 100 + }, + { + key: 'targetdbname', + name: ContainerCopyMessages.targetDatabaseLabel, + fieldName: 'targetDatabaseName', + minWidth: 100 + }, + { + key: 'targetcolname', + name: ContainerCopyMessages.targetContainerLabel, + fieldName: 'targetContainerName', + minWidth: 100 + } + ]; +}; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/index.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/index.tsx new file mode 100644 index 000000000..98401c047 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/index.tsx @@ -0,0 +1,53 @@ +import { DetailsList, DetailsListLayoutMode, Stack, Text, TextField } from '@fluentui/react'; +import FieldRow from 'Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow'; +import React from 'react'; +import ContainerCopyMessages from '../../../ContainerCopyMessages'; +import { useCopyJobContext } from '../../../Context/CopyJobContext'; +import { getPreviewCopyJobDetailsListColumns } from './Utils/PreviewCopyJobUtils'; + +const PreviewCopyJob: React.FC = () => { + const { copyJobState, setCopyJobState } = useCopyJobContext(); + + const selectedDatabaseAndContainers = [{ + sourceDatabaseName: copyJobState.source?.databaseId, + sourceContainerName: copyJobState.source?.containerId, + targetDatabaseName: copyJobState.target?.databaseId, + targetContainerName: copyJobState.target?.containerId, + }]; + const jobName = copyJobState.jobName; + + const onJobNameChange = (_ev?: React.FormEvent, newValue?: string) => { + setCopyJobState((prevState) => ({ + ...prevState, + jobName: newValue || '', + })); + }; + return ( + + + + + + {ContainerCopyMessages.sourceSubscriptionLabel} + {copyJobState.source?.subscription?.displayName} + + + {ContainerCopyMessages.sourceAccountLabel} + {copyJobState.source?.account?.name} + + + + + + ); +}; + +export default PreviewCopyJob; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx new file mode 100644 index 000000000..c499259cb --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx @@ -0,0 +1,28 @@ +import { Dropdown } from "@fluentui/react"; +import React from "react"; +import ContainerCopyMessages from "../../../../ContainerCopyMessages"; +import { DropdownOptionType } from "../../../../Types"; +import FieldRow from "../../Components/FieldRow"; + +interface AccountDropdownProps { + options: DropdownOptionType[]; + selectedKey?: string; + disabled: boolean; + onChange: (_ev?: React.FormEvent, option?: DropdownOptionType) => void; +} + +export const AccountDropdown: React.FC = React.memo( + ({ options, selectedKey, disabled, onChange }) => ( + + + + ) +); \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx new file mode 100644 index 000000000..ba081bfdd --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx @@ -0,0 +1,20 @@ +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 = React.memo( + ({ checked, onChange }) => ( + + + + ) +); \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.tsx new file mode 100644 index 000000000..8c511d9aa --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.tsx @@ -0,0 +1,26 @@ +import { Dropdown } from "@fluentui/react"; +import React from "react"; +import ContainerCopyMessages from "../../../../ContainerCopyMessages"; +import { DropdownOptionType } from "../../../../Types"; +import FieldRow from "../../Components/FieldRow"; + +interface SubscriptionDropdownProps { + options: DropdownOptionType[]; + selectedKey?: string; + onChange: (_ev?: React.FormEvent, option?: DropdownOptionType) => void; +} + +export const SubscriptionDropdown: React.FC = React.memo( + ({ options, selectedKey, onChange }) => ( + + + + ) +); \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx new file mode 100644 index 000000000..2a79285ea --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { DatabaseAccount, Subscription } from "../../../../../../Contracts/DataModels"; +import { CopyJobContextProviderType, CopyJobContextState, DropdownOptionType } from "../../../../Types"; + +export function useDropdownOptions( + subscriptions: Subscription[], + accounts: DatabaseAccount[] +): { + subscriptionOptions: DropdownOptionType[], + accountOptions: DropdownOptionType[] +} { + const subscriptionOptions = React.useMemo( + () => + subscriptions?.map((sub) => ({ + key: sub.subscriptionId, + text: sub.displayName, + data: sub, + })) || [], + [subscriptions] + ); + + const accountOptions = React.useMemo( + () => + accounts?.map((account) => ({ + key: account.id, + text: account.name, + data: account, + })) || [], + [accounts] + ); + + return { subscriptionOptions, accountOptions }; +} + +type setCopyJobStateType = CopyJobContextProviderType["setCopyJobState"]; + +export function useEventHandlers(setCopyJobState: setCopyJobStateType) { + const handleSelectSourceAccount = React.useCallback( + (type: "subscription" | "account", data: Subscription & DatabaseAccount | undefined) => { + setCopyJobState((prevState: CopyJobContextState) => { + if (type === "subscription") { + return { + ...prevState, + source: { + ...prevState.source, + subscription: data || null, + account: null, // reset on subscription change + }, + }; + } + if (type === "account") { + return { + ...prevState, + source: { + ...prevState.source, + account: data || null, + }, + }; + } + return prevState; + }); + }, + [setCopyJobState] + ); + + const handleMigrationTypeChange = React.useCallback( + (_ev?: React.FormEvent, checked?: boolean) => { + setCopyJobState((prevState: CopyJobContextState) => ({ + ...prevState, + migrationType: checked ? "offline" : "online", + })); + }, + [setCopyJobState] + ); + + return { handleSelectSourceAccount, handleMigrationTypeChange }; +} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/index.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/index.tsx new file mode 100644 index 000000000..241a59536 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/index.tsx @@ -0,0 +1,54 @@ +import { Stack } from "@fluentui/react"; +import React from "react"; +import { DatabaseAccount, Subscription } from "../../../../../Contracts/DataModels"; +import { useDatabaseAccounts } from "../../../../../hooks/useDatabaseAccounts"; +import { useSubscriptions } from "../../../../../hooks/useSubscriptions"; +import ContainerCopyMessages from "../../../ContainerCopyMessages"; +import { useCopyJobContext } from "../../../Context/CopyJobContext"; +import { AccountDropdown } from "./Components/AccountDropdown"; +import { MigrationTypeCheckbox } from "./Components/MigrationTypeCheckbox"; +import { SubscriptionDropdown } from "./Components/SubscriptionDropdown"; +import { useDropdownOptions, useEventHandlers } from "./Utils/selectAccountUtils"; + +interface SelectAccountProps { } + +const SelectAccount = React.memo( + (_props: SelectAccountProps) => { + const { armToken, copyJobState, setCopyJobState } = useCopyJobContext(); + const selectedSubscriptionId = copyJobState?.source?.subscription?.subscriptionId; + + const subscriptions: Subscription[] = useSubscriptions(armToken); + const accounts: DatabaseAccount[] = useDatabaseAccounts(selectedSubscriptionId, armToken); + + const { subscriptionOptions, accountOptions } = useDropdownOptions(subscriptions, accounts); + const { handleSelectSourceAccount, handleMigrationTypeChange } = useEventHandlers(setCopyJobState); + + const migrationTypeChecked = copyJobState?.migrationType === "offline"; + + return ( + + {ContainerCopyMessages.selectAccountDescription} + + handleSelectSourceAccount("subscription", option?.data)} + /> + + handleSelectSourceAccount("account", option?.data)} + /> + + + + ); + } +); + +export default SelectAccount; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/Events/DropDownChangeHandler.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/Events/DropDownChangeHandler.tsx new file mode 100644 index 000000000..94037c4bf --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/Events/DropDownChangeHandler.tsx @@ -0,0 +1,36 @@ +import { CopyJobContextState, DropdownOptionType } from "../../../../Types"; + +export function dropDownChangeHandler( + setCopyJobState: React.Dispatch> +) { + return (type: "sourceDatabase" | "sourceContainer" | "targetDatabase" | "targetContainer") => + (_evnt: any, option: DropdownOptionType) => { + const value = option.key; + setCopyJobState((prevState) => { + switch (type) { + case "sourceDatabase": + return { + ...prevState, + source: { ...prevState.source, databaseId: value, containerId: undefined } + }; + case "sourceContainer": + return { + ...prevState, + source: { ...prevState.source, containerId: value } + }; + case "targetDatabase": + return { + ...prevState, + target: { ...prevState.target, databaseId: value, containerId: undefined } + }; + case "targetContainer": + return { + ...prevState, + target: { ...prevState.target, containerId: value } + }; + default: + return prevState; + } + }); + } +} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.tsx new file mode 100644 index 000000000..8bb4c61d6 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.tsx @@ -0,0 +1,43 @@ +import { Dropdown, Stack } from "@fluentui/react"; +import React from "react"; +import ContainerCopyMessages from "../../../../ContainerCopyMessages"; +import { DatabaseContainerSectionProps } from "../../../../Types"; +import FieldRow from "../../Components/FieldRow"; + +export const DatabaseContainerSection = ({ + heading, + databaseOptions, + selectedDatabase, + databaseDisabled, + databaseOnChange, + containerOptions, + selectedContainer, + containerDisabled, + containerOnChange +}: DatabaseContainerSectionProps) => ( + + + + + + + + + +); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/index.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/index.tsx new file mode 100644 index 000000000..88d642755 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/index.tsx @@ -0,0 +1,80 @@ +import { Stack } from "@fluentui/react"; +import React from "react"; +import { useDatabases } from "../../../../../hooks/useDatabases"; +import { useDataContainers } from "../../../../../hooks/useDataContainers"; +import ContainerCopyMessages from "../../../ContainerCopyMessages"; +import { useCopyJobContext } from "../../../Context/CopyJobContext"; +import { DatabaseContainerSection } from "./components/DatabaseContainerSection"; +import { dropDownChangeHandler } from "./Events/DropDownChangeHandler"; +import { useMemoizedSourceAndTargetData } from "./memoizedData"; + +interface SelectSourceAndTargetContainersProps { } + +const SelectSourceAndTargetContainers = (_props: SelectSourceAndTargetContainersProps) => { + const { armToken, copyJobState, setCopyJobState } = useCopyJobContext(); + const { + source, + target, + sourceDbParams, + sourceContainerParams, + targetDbParams, + targetContainerParams + } = useMemoizedSourceAndTargetData(copyJobState, armToken); + + // Custom hooks + const sourceDatabases = useDatabases(...sourceDbParams) || []; + const sourceContainers = useDataContainers(...sourceContainerParams) || []; + const targetDatabases = useDatabases(...targetDbParams) || []; + const targetContainers = useDataContainers(...targetContainerParams) || []; + + // Memoize option objects for dropdowns + const sourceDatabaseOptions = React.useMemo( + () => sourceDatabases.map((db: any) => ({ key: db.name, text: db.name, data: db })), + [sourceDatabases] + ); + const sourceContainerOptions = React.useMemo( + () => sourceContainers.map((c: any) => ({ key: c.name, text: c.name, data: c })), + [sourceContainers] + ); + const targetDatabaseOptions = React.useMemo( + () => targetDatabases.map((db: any) => ({ key: db.name, text: db.name, data: db })), + [targetDatabases] + ); + const targetContainerOptions = React.useMemo( + () => targetContainers.map((c: any) => ({ key: c.name, text: c.name, data: c })), + [targetContainers] + ); + + const onDropdownChange = React.useCallback(dropDownChangeHandler(setCopyJobState), [setCopyJobState]); + + return ( + + {ContainerCopyMessages.selectSourceAndTargetContainersDescription} + + + + ); +}; + + +export default SelectSourceAndTargetContainers; \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.tsx new file mode 100644 index 000000000..077b0fad7 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { CopyJobContextState, DatabaseParams, DataContainerParams } from "../../../Types"; + +export function useMemoizedSourceAndTargetData(copyJobState: CopyJobContextState, armToken: string) { + const { source, target } = copyJobState ?? {}; + const selectedSourceAccount = source?.account; + const selectedTargetAccount = target?.account; + + const sourceDbParams = React.useMemo( + () => + [ + armToken, + source?.subscription?.subscriptionId, + selectedSourceAccount?.resourceGroup, + selectedSourceAccount?.name, + 'SQL', + ] as DatabaseParams, + [armToken, source?.subscription?.subscriptionId, selectedSourceAccount] + ); + + const sourceContainerParams = React.useMemo( + () => + [ + armToken, + source?.subscription?.subscriptionId, + selectedSourceAccount?.resourceGroup, + selectedSourceAccount?.name, + source?.databaseId, + 'SQL', + ] as DataContainerParams, + [armToken, source?.subscription?.subscriptionId, selectedSourceAccount, source?.databaseId] + ); + + const targetDbParams = React.useMemo( + () => [ + armToken, + target?.subscriptionId, + selectedTargetAccount?.resourceGroup, + selectedTargetAccount?.name, + 'SQL', + ] as DatabaseParams, + [armToken, target?.subscriptionId, selectedTargetAccount] + ); + + const targetContainerParams = React.useMemo( + () => [ + armToken, + target?.subscriptionId, + selectedTargetAccount?.resourceGroup, + selectedTargetAccount?.name, + target?.databaseId, + 'SQL', + ] as DataContainerParams, + [armToken, target?.subscriptionId, selectedTargetAccount, target?.databaseId] + ); + + return { source, target, sourceDbParams, sourceContainerParams, targetDbParams, targetContainerParams }; +} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts new file mode 100644 index 000000000..ffe9ef86e --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts @@ -0,0 +1,85 @@ +import { submitCreateCopyJob } from "Explorer/ContainerCopy/Actions/CopyJobActions"; +import { useCallback, useMemo, useReducer } from "react"; +import { useSidePanel } from "../../../../hooks/useSidePanel"; +import { CopyJobContextState } from "../../Types"; +import { SCREEN_KEYS, useCreateCopyJobScreensList } from "./useCreateCopyJobScreensList"; + +type NavigationState = { + screenHistory: string[]; +}; + +type Action = + | { type: "NEXT"; nextScreen: string } + | { type: "PREVIOUS" } + | { type: "RESET" }; + +function navigationReducer(state: NavigationState, action: Action): NavigationState { + switch (action.type) { + case "NEXT": + return { + screenHistory: [...state.screenHistory, action.nextScreen], + }; + case "PREVIOUS": + return { + screenHistory: state.screenHistory.length > 1 ? state.screenHistory.slice(0, -1) : state.screenHistory, + }; + case "RESET": + return { + screenHistory: [SCREEN_KEYS.SelectAccount], + }; + default: + return state; + } +} + +export function useCopyJobNavigation(copyJobState: CopyJobContextState) { + const screens = useCreateCopyJobScreensList(); + const [state, dispatch] = useReducer(navigationReducer, { screenHistory: [SCREEN_KEYS.SelectAccount] }); + + const currentScreenKey = state.screenHistory[state.screenHistory.length - 1]; + const currentScreen = screens.find((screen) => screen.key === currentScreenKey); + + const isPrimaryDisabled = useMemo( + () => !currentScreen?.validations.every((v) => v.validate(copyJobState)), + [currentScreen.key, copyJobState] + ); + const primaryBtnText = useMemo(() => { + if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) { + return "Copy"; + } + return "Next"; + }, [currentScreenKey]); + + const isPreviousDisabled = state.screenHistory.length <= 1; + + const handlePrimary = useCallback(() => { + if (currentScreenKey === SCREEN_KEYS.SelectAccount) { + dispatch({ type: "NEXT", nextScreen: SCREEN_KEYS.SelectSourceAndTargetContainers }); + } + if (currentScreenKey === SCREEN_KEYS.SelectSourceAndTargetContainers) { + dispatch({ type: "NEXT", nextScreen: SCREEN_KEYS.PreviewCopyJob }); + } + if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) { + submitCreateCopyJob(copyJobState); + } + }, [currentScreenKey, copyJobState]); + + const handlePrevious = useCallback(() => { + dispatch({ type: "PREVIOUS" }); + }, []); + + const handleCancel = useCallback(() => { + dispatch({ type: "RESET" }); + useSidePanel.getState().closeSidePanel(); + }, []); + + return { + currentScreen, + isPrimaryDisabled, + isPreviousDisabled, + handlePrimary, + handlePrevious, + handleCancel, + primaryBtnText, + }; +} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx new file mode 100644 index 000000000..1d6108ce7 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { CopyJobContextState } from "../../Types"; +import PreviewCopyJob from "../Screens/PreviewCopyJob"; +import SelectAccount from "../Screens/SelectAccount"; +import SelectSourceAndTargetContainers from "../Screens/SelectSourceAndTargetContainers"; + +const SCREEN_KEYS = { + SelectAccount: "SelectAccount", + SelectSourceAndTargetContainers: "SelectSourceAndTargetContainers", + PreviewCopyJob: "PreviewCopyJob", +}; + +type Validation = { + validate: (state: CopyJobContextState) => boolean; + message: string; +}; + +type Screen = { + key: string; + component: React.ReactElement; + validations: Validation[]; +}; + +function useCreateCopyJobScreensList() { + return React.useMemo( + () => [ + { + key: SCREEN_KEYS.SelectAccount, + component: , + validations: [ + { + validate: (state) => !!state?.source?.subscription && !!state?.source?.account, + message: "Please select a subscription and account to proceed", + }, + ], + }, + { + key: SCREEN_KEYS.SelectSourceAndTargetContainers, + component: , + validations: [ + { + validate: (state) => ( + !!state?.source?.databaseId && !!state?.source?.containerId && !!state?.target?.databaseId && !!state?.target?.containerId + ), + message: "Please select source and target containers to proceed", + }, + ], + }, + { + key: SCREEN_KEYS.PreviewCopyJob, + component: , + validations: [], + }, + ], + [] + ); +} + +export { SCREEN_KEYS, useCreateCopyJobScreensList }; + diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx new file mode 100644 index 000000000..e1da73a23 --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx @@ -0,0 +1,23 @@ +import { ActionButton, Image } from '@fluentui/react'; +import React, { useCallback } from 'react'; +import CopyJobIcon from "../../../../../images/ContainerCopy/copy-jobs.svg"; +import * as Actions from "../../Actions/CopyJobActions"; +import ContainerCopyMessages from '../../ContainerCopyMessages'; + +interface CopyJobsNotFoundProps { } + +const CopyJobsNotFound: React.FC = () => { + + const handleCreateCopyJob = useCallback(Actions.openCreateCopyJobPanel, []); + return ( +
+ {ContainerCopyMessages.noCopyJobsTitle} +

{ContainerCopyMessages.noCopyJobsTitle}

+ + {ContainerCopyMessages.createCopyJobButtonText} + +
+ ); +} + +export default CopyJobsNotFound; diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx new file mode 100644 index 000000000..9dce7fe3b --- /dev/null +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import CopyJobsNotFound from '../MonitorCopyJobs/Components/CopyJobs.NotFound'; + +interface MonitorCopyJobsProps { } + +const MonitorCopyJobs: React.FC = () => { + return ( +
+ +
+ ); +} + +export default MonitorCopyJobs; \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/Types/index.ts b/src/Explorer/ContainerCopy/Types/index.ts new file mode 100644 index 000000000..5d66e8d01 --- /dev/null +++ b/src/Explorer/ContainerCopy/Types/index.ts @@ -0,0 +1,99 @@ +import { DatabaseAccount, Subscription } from "Contracts/DataModels"; +import React from "react"; +import { ApiType } from "UserContext"; +import Explorer from "../../Explorer"; + +export interface ContainerCopyProps { + container: Explorer; +} + +export type CopyJobCommandBarBtnType = { + key: string; + iconSrc: string; + label: string; + ariaLabel: string; + disabled?: boolean; + onClick: () => void; +}; + +export type CopyJobTabForwardRefHandle = { + validate: (state: CopyJobContextState) => boolean; +}; + +export type DropdownOptionType = { + key: string, + text: string, + data: any +}; + +export type FetchDatabasesListParams = { + armToken: string; + subscriptionId: string; + resourceGroupName: string; + accountName: string; + apiType?: ApiType; +}; + +export interface FetchDataContainersListParams extends FetchDatabasesListParams { + databaseName: string; +} + +export type DatabaseParams = [ + string, + string | undefined, + string | undefined, + string | undefined, + ApiType +]; +export type DataContainerParams = [ + string, + string | undefined, + string | undefined, + string | undefined, + string | undefined, + ApiType +]; + +export interface DatabaseContainerSectionProps { + heading: string, + databaseOptions: DropdownOptionType[], + selectedDatabase: string, + databaseDisabled?: boolean, + databaseOnChange: (ev: any, option: DropdownOptionType) => void, + containerOptions: DropdownOptionType[], + selectedContainer: string, + containerDisabled?: boolean, + containerOnChange: (ev: any, option: DropdownOptionType) => void +} + +export interface CopyJobContextState { + jobName: string; + migrationType: "online" | "offline"; + // source details + source: { + subscription: Subscription; + account: DatabaseAccount; + databaseId: string; + containerId: string; + }, + // target details + target: { + subscriptionId: string; + account: DatabaseAccount; + databaseId: string; + containerId: string; + }, +} + +export interface CopyJobFlowType { + currentScreen: string; +} + +export interface CopyJobContextProviderType { + armToken: string; + flow: CopyJobFlowType; + setFlow: React.Dispatch>; + copyJobState: CopyJobContextState | null; + setCopyJobState: React.Dispatch>; + resetCopyJobState: () => void; +} \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/containerCopyStyles.less b/src/Explorer/ContainerCopy/containerCopyStyles.less new file mode 100644 index 000000000..b88df2d70 --- /dev/null +++ b/src/Explorer/ContainerCopy/containerCopyStyles.less @@ -0,0 +1,49 @@ +@import "../../../less/Common/Constants.less"; + +#containerCopyWrapper { + .centerContent { + justify-content: center; + align-items: center; + } + .notFoundContainer { + .noCopyJobsMessage { + font-weight: 600; + margin: 0 auto; + color: @FocusColor; + } + button.createCopyJobButton { + color: @LinkColor; + } + } +} +.createCopyJobScreensContainer { + height: 100%; + padding: 1em 1.5em; + .bold { + font-weight: 600; + } + label.field-label { + padding: 0; + } + .flex-row { + display: flex; + flex-direction: row; + label.field-label { + font-weight: 600; + } + .flex-fixed-width { + flex: 0 0 auto; + width: 150px; + } + .flex-grow-col { + flex: 1 1 auto; + } + } + .databaseContainerSection { + label.subHeading { + font: inherit; + padding: unset; + font-weight: 600; + } + } +} diff --git a/src/Explorer/ContainerCopy/index.tsx b/src/Explorer/ContainerCopy/index.tsx new file mode 100644 index 000000000..ca0bbd2fd --- /dev/null +++ b/src/Explorer/ContainerCopy/index.tsx @@ -0,0 +1,19 @@ +import React, { Suspense } from 'react'; +import CopyJobCommandBar from './CommandBar/CopyJobCommandBar'; +import { ContainerCopyProps } from './Types'; +import './containerCopyStyles.less'; + +const MonitorCopyJobs = React.lazy(() => import('./MonitorCopyJobs/MonitorCopyJobs')); + +const ContainerCopyPanel: React.FC = ({ container }) => { + return ( +
+ + Loading...
}> + + + + ); +}; + +export default ContainerCopyPanel; \ No newline at end of file diff --git a/src/Explorer/Panes/PanelContainerComponent.tsx b/src/Explorer/Panes/PanelContainerComponent.tsx index 5795ec941..871c1dd5d 100644 --- a/src/Explorer/Panes/PanelContainerComponent.tsx +++ b/src/Explorer/Panes/PanelContainerComponent.tsx @@ -8,6 +8,7 @@ export interface PanelContainerProps { panelContent?: JSX.Element; isConsoleExpanded: boolean; isOpen: boolean; + hasConsole: boolean isConsoleAnimationFinished?: boolean; panelWidth?: string; onRenderNavigationContent?: IRenderFunction; @@ -86,6 +87,9 @@ export class PanelContainerComponent extends React.Component { + if (!this.props.hasConsole) { + return window.innerHeight + "px"; + } const notificationConsole = document.getElementById("explorerNotificationConsole"); if (notificationConsole) { return window.innerHeight - notificationConsole.clientHeight + "px"; @@ -102,9 +106,10 @@ export class PanelContainerComponent extends React.Component { const isConsoleExpanded = useNotificationConsole((state) => state.isExpanded); const isConsoleAnimationFinished = useNotificationConsole((state) => state.consoleAnimationFinished); - const { isOpen, panelContent, panelWidth, headerText } = useSidePanel((state) => { + const { isOpen, hasConsole, panelContent, panelWidth, headerText } = useSidePanel((state) => { return { isOpen: state.isOpen, + hasConsole: state.hasConsole, panelContent: state.panelContent, headerText: state.headerText, panelWidth: state.panelWidth, @@ -114,6 +119,7 @@ export const SidePanel: React.FC = () => { // This component only exists so we can use hooks and pass them down to a non-functional component return ( { } StyleConstants.updateStyles(); const explorer = useKnockoutExplorer(config?.platform); + // console.log("Using config: ", config); if (!explorer) { return ; } + // console.log("Using explorer: ", explorer); + // console.log("Using userContext: ", userContext); return (
-
-
- {/* Main Command Bar - Start */} - - {/* Collections Tree and Tabs - Begin */} - - {/* Collections Tree and Tabs - End */} - -
+ { + userContext.features.enableContainerCopy && userContext.apiType === "SQL" ? ( + + ) : ( + + ) + } + {} @@ -113,6 +111,27 @@ const App: React.FunctionComponent = () => { const mainElement = document.getElementById("Main"); ReactDOM.render(, mainElement); +function DivExplorer({ explorer }: { explorer: Explorer }): JSX.Element { + return ( +
+
+ {/* Main Command Bar - Start */} + + {/* Collections Tree and Tabs - Begin */} + + {/* Collections Tree and Tabs - End */} + +
+ ); +} + function LoadingExplorer(): JSX.Element { return (
diff --git a/src/Platform/Hosted/extractFeatures.ts b/src/Platform/Hosted/extractFeatures.ts index 685930b43..80a5494a4 100644 --- a/src/Platform/Hosted/extractFeatures.ts +++ b/src/Platform/Hosted/extractFeatures.ts @@ -39,6 +39,7 @@ export type Features = { readonly copilotChatFixedMonacoEditorHeight: boolean; readonly enablePriorityBasedExecution: boolean; readonly disableConnectionStringLogin: boolean; + readonly enableContainerCopy: boolean; readonly enableCloudShell: boolean; // can be set via both flight and feature flag @@ -111,6 +112,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear copilotChatFixedMonacoEditorHeight: "true" === get("copilotchatfixedmonacoeditorheight"), enablePriorityBasedExecution: "true" === get("enableprioritybasedexecution"), disableConnectionStringLogin: "true" === get("disableconnectionstringlogin"), + enableContainerCopy: "true" === get("enablecontainercopy"), enableCloudShell: true, }; } diff --git a/src/hooks/useDataContainers.tsx b/src/hooks/useDataContainers.tsx new file mode 100644 index 000000000..e8702a7d4 --- /dev/null +++ b/src/hooks/useDataContainers.tsx @@ -0,0 +1,82 @@ +import { DatabaseModel } from "Contracts/DataModels"; +import useSWR from "swr"; +import { getCollectionEndpoint, getDatabaseEndpoint } from "../Common/DatabaseAccountUtility"; +import { configContext } from "../ConfigContext"; +import { FetchDataContainersListParams } from "../Explorer/ContainerCopy/Types"; +import { ApiType } from "../UserContext"; + +const apiVersion = "2023-09-15"; + +const buildReadDataContainersListUrl = (params: FetchDataContainersListParams): string => { + const { subscriptionId, resourceGroupName, accountName, databaseName, apiType } = params; + const databaseEndpoint = getDatabaseEndpoint(apiType); + const collectionEndpoint = getCollectionEndpoint(apiType); + + let armEndpoint = configContext.ARM_ENDPOINT; + if (armEndpoint.endsWith("/")) { + armEndpoint = armEndpoint.slice(0, -1); + } + return `${armEndpoint}/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/${databaseEndpoint}/${databaseName}/${collectionEndpoint}?api-version=${apiVersion}`; +} + +const fetchDataContainersList = async ( + armToken: string, + subscriptionId: string, + resourceGroupName: string, + accountName: string, + databaseName: string, + apiType: ApiType +): Promise => { + const uri = buildReadDataContainersListUrl({ + armToken, + subscriptionId, + resourceGroupName, + accountName, + databaseName, + apiType + }); + const headers = new Headers(); + const bearer = `Bearer ${armToken}`; + headers.append("Authorization", bearer); + headers.append("Content-Type", "application/json"); + + const response = await fetch(uri, { + method: "GET", + headers: headers + }); + + if (!response.ok) { + throw new Error("Failed to fetch containers"); + } + + const data = await response.json(); + return data.value; +}; + +export function useDataContainers( + armToken: string, + subscriptionId: string, + resourceGroupName: string, + accountName: string, + databaseName: string, + apiType: ApiType +): DatabaseModel[] | undefined { + const { data } = useSWR( + () => ( + armToken && subscriptionId && resourceGroupName && accountName && databaseName && apiType ? [ + "fetchContainersLinkedToDatabases", + armToken, subscriptionId, resourceGroupName, accountName, databaseName, apiType + ] : undefined + ), + (_, armToken, subscriptionId, resourceGroupName, accountName, databaseName, apiType) => fetchDataContainersList( + armToken, + subscriptionId, + resourceGroupName, + accountName, + databaseName, + apiType + ), + ); + + return data; +} \ No newline at end of file diff --git a/src/hooks/useDatabases.tsx b/src/hooks/useDatabases.tsx new file mode 100644 index 000000000..24ca29bbd --- /dev/null +++ b/src/hooks/useDatabases.tsx @@ -0,0 +1,64 @@ +import { DatabaseModel } from "Contracts/DataModels"; +import useSWR from "swr"; +import { getDatabaseEndpoint } from "../Common/DatabaseAccountUtility"; +import { configContext } from "../ConfigContext"; +import { FetchDatabasesListParams } from "../Explorer/ContainerCopy/Types"; +import { ApiType } from "../UserContext"; + +const apiVersion = "2023-09-15"; +const buildReadDatabasesListUrl = (params: FetchDatabasesListParams): string => { + const { subscriptionId, resourceGroupName, accountName, apiType } = params; + const databaseEndpoint = getDatabaseEndpoint(apiType); + + let armEndpoint = configContext.ARM_ENDPOINT; + if (armEndpoint.endsWith("/")) { + armEndpoint = armEndpoint.slice(0, -1); + } + return `${armEndpoint}/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/${databaseEndpoint}?api-version=${apiVersion}`; +} + +const fetchDatabasesList = async (armToken: string, subscriptionId: string, resourceGroupName: string, accountName: string, apiType: ApiType): Promise => { + const uri = buildReadDatabasesListUrl({ armToken, subscriptionId, resourceGroupName, accountName, apiType }); + const headers = new Headers(); + const bearer = `Bearer ${armToken}`; + headers.append("Authorization", bearer); + headers.append("Content-Type", "application/json"); + + const response = await fetch(uri, { + method: "GET", + headers: headers + }); + + if (!response.ok) { + throw new Error("Failed to fetch databases"); + } + + const data = await response.json(); + return data.value; +}; + +export function useDatabases( + armToken: string, + subscriptionId: string, + resourceGroupName: string, + accountName: string, + apiType: ApiType +): DatabaseModel[] | undefined { + const { data } = useSWR( + () => ( + armToken && subscriptionId && resourceGroupName && accountName && apiType ? [ + "fetchDatabasesLinkedToResource", + armToken, subscriptionId, resourceGroupName, accountName, apiType + ] : undefined + ), + (_, armToken, subscriptionId, resourceGroupName, accountName, apiType) => fetchDatabasesList( + armToken, + subscriptionId, + resourceGroupName, + accountName, + apiType + ), + ); + + return data; +} \ No newline at end of file diff --git a/src/hooks/useKnockoutExplorer.ts b/src/hooks/useKnockoutExplorer.ts index 46e655a87..e5d4160ae 100644 --- a/src/hooks/useKnockoutExplorer.ts +++ b/src/hooks/useKnockoutExplorer.ts @@ -959,6 +959,13 @@ function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) { if (inputs.features) { Object.assign(userContext.features, extractFeatures(new URLSearchParams(inputs.features))); } + // somehow we need to make enableContainerCopy true for platform: portal & apiType: SQL + // Ideally we need to pass this as a feature flag from portal or in a query param + // Then we will fetch the value from query param and set this to true + // For now setting it to true unconditionally + if (userContext.apiType === "SQL") { + Object.assign(userContext.features, { enableContainerCopy: true }); + } if (inputs.flights) { if (inputs.flights.indexOf(Flights.AutoscaleTest) !== -1) { diff --git a/src/hooks/useSidePanel.ts b/src/hooks/useSidePanel.ts index 8f7eab69b..25b87f346 100644 --- a/src/hooks/useSidePanel.ts +++ b/src/hooks/useSidePanel.ts @@ -3,15 +3,19 @@ import create, { UseStore } from "zustand"; export interface SidePanelState { isOpen: boolean; panelWidth: string; + hasConsole: boolean; panelContent?: JSX.Element; headerText?: string; openSidePanel: (headerText: string, panelContent: JSX.Element, panelWidth?: string, onClose?: () => void) => void; closeSidePanel: () => void; + setPanelHasConsole: (hasConsole: boolean) => void; getRef?: React.RefObject; // Optional ref for focusing the last element. } export const useSidePanel: UseStore = create((set) => ({ isOpen: false, panelWidth: "440px", + hasConsole: true, + setPanelHasConsole: (hasConsole: boolean) => set((state) => ({ ...state, hasConsole })), openSidePanel: (headerText, panelContent, panelWidth = "440px") => set((state) => ({ ...state, headerText, panelContent, panelWidth, isOpen: true })), closeSidePanel: () => {