mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2024-11-25 15:06:55 +00:00
Save and restore DocumentsTab state to local storage (#1919)
* Infrastructure to save app state * Save filters * Replace read/save methods with more generic ones * Make datalist for filter unique per database/container combination * Disable saving middle split position for now * Fix unit tests * Turn off confusing auto-complete from input box * Disable tabStateData for now * Save and restore split position * Fix replace autocomplete="off" by removing id on Input tag * Properly set allotment width * Fix saved percentage * Save splitter per collection * Add error handling and telemetry * Fix compiling issue * Add ability to delete filter history. Bug fix when hitting Enter on filter input box. * Replace delete filter modal with dropdown menu * Add code to remove oldest record if max limit is reached in app state persistence * Only save new splitter position on drag end (not onchange) * Add unit tests * Add Clear all in settings. Update snapshots * Fix format * Remove filter delete and keep filter history to a max. Reword clear button and message in settings pane. * Fix setting button label * Update test snapshots * Reword Clear history button text * Update unit test snapshot * Enable Settings pane for Fabric, but turn off Rbac dial for Fabric. * Change union type to enum * Update src/Shared/AppStatePersistenceUtility.ts Assert that path does not include slash char. Co-authored-by: Ashley Stanton-Nurse <ashleyst@microsoft.com> * Update src/Shared/AppStatePersistenceUtility.ts Assert that path does not contain slash. Co-authored-by: Ashley Stanton-Nurse <ashleyst@microsoft.com> * Fix format --------- Co-authored-by: Ashley Stanton-Nurse <ashleyst@microsoft.com>
This commit is contained in:
parent
94d3fcb30f
commit
038142c180
@ -167,22 +167,18 @@ export function createContextCommandBarButtons(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createControlCommandBarButtons(container: Explorer): CommandButtonComponentProps[] {
|
export function createControlCommandBarButtons(container: Explorer): CommandButtonComponentProps[] {
|
||||||
const buttons: CommandButtonComponentProps[] =
|
const buttons: CommandButtonComponentProps[] = [
|
||||||
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
|
{
|
||||||
? []
|
iconSrc: SettingsIcon,
|
||||||
: [
|
iconAlt: "Settings",
|
||||||
{
|
onCommandClick: () => useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
|
||||||
iconSrc: SettingsIcon,
|
commandButtonLabel: undefined,
|
||||||
iconAlt: "Settings",
|
ariaLabel: "Settings",
|
||||||
onCommandClick: () =>
|
tooltipText: "Settings",
|
||||||
useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
|
hasPopup: true,
|
||||||
commandButtonLabel: undefined,
|
disabled: false,
|
||||||
ariaLabel: "Settings",
|
},
|
||||||
tooltipText: "Settings",
|
];
|
||||||
hasPopup: true,
|
|
||||||
disabled: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const showOpenFullScreen =
|
const showOpenFullScreen =
|
||||||
configContext.platform === Platform.Portal && !isRunningOnNationalCloud() && userContext.apiType !== "Gremlin";
|
configContext.platform === Platform.Portal && !isRunningOnNationalCloud() && userContext.apiType !== "Gremlin";
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Checkbox,
|
Checkbox,
|
||||||
ChoiceGroup,
|
ChoiceGroup,
|
||||||
|
DefaultButton,
|
||||||
IChoiceGroupOption,
|
IChoiceGroupOption,
|
||||||
ISpinButtonStyles,
|
ISpinButtonStyles,
|
||||||
IToggleStyles,
|
IToggleStyles,
|
||||||
@ -12,11 +13,15 @@ import {
|
|||||||
Toggle,
|
Toggle,
|
||||||
TooltipHost,
|
TooltipHost,
|
||||||
} from "@fluentui/react";
|
} from "@fluentui/react";
|
||||||
|
import { makeStyles } from "@fluentui/react-components";
|
||||||
|
import { AuthType } from "AuthType";
|
||||||
import * as Constants from "Common/Constants";
|
import * as Constants from "Common/Constants";
|
||||||
import { SplitterDirection } from "Common/Splitter";
|
import { SplitterDirection } from "Common/Splitter";
|
||||||
import { InfoTooltip } from "Common/Tooltip/InfoTooltip";
|
import { InfoTooltip } from "Common/Tooltip/InfoTooltip";
|
||||||
import { Platform, configContext } from "ConfigContext";
|
import { Platform, configContext } from "ConfigContext";
|
||||||
|
import { useDialog } from "Explorer/Controls/Dialog";
|
||||||
import { useDatabases } from "Explorer/useDatabases";
|
import { useDatabases } from "Explorer/useDatabases";
|
||||||
|
import { deleteAllStates } from "Shared/AppStatePersistenceUtility";
|
||||||
import {
|
import {
|
||||||
DefaultRUThreshold,
|
DefaultRUThreshold,
|
||||||
LocalStorageUtility,
|
LocalStorageUtility,
|
||||||
@ -29,14 +34,13 @@ import * as StringUtility from "Shared/StringUtility";
|
|||||||
import { updateUserContext, userContext } from "UserContext";
|
import { updateUserContext, userContext } from "UserContext";
|
||||||
import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils";
|
import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils";
|
||||||
import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils";
|
import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils";
|
||||||
|
import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||||
import { useQueryCopilot } from "hooks/useQueryCopilot";
|
import { useQueryCopilot } from "hooks/useQueryCopilot";
|
||||||
import { useSidePanel } from "hooks/useSidePanel";
|
import { useSidePanel } from "hooks/useSidePanel";
|
||||||
import React, { FunctionComponent, useState } from "react";
|
import React, { FunctionComponent, useState } from "react";
|
||||||
|
import create, { UseStore } from "zustand";
|
||||||
import Explorer from "../../Explorer";
|
import Explorer from "../../Explorer";
|
||||||
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
|
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
|
||||||
import { AuthType } from "AuthType";
|
|
||||||
import create, { UseStore } from "zustand";
|
|
||||||
import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
|
|
||||||
|
|
||||||
export interface DataPlaneRbacState {
|
export interface DataPlaneRbacState {
|
||||||
dataPlaneRbacEnabled: boolean;
|
dataPlaneRbacEnabled: boolean;
|
||||||
@ -50,6 +54,13 @@ export interface DataPlaneRbacState {
|
|||||||
|
|
||||||
type DataPlaneRbacStore = UseStore<Partial<DataPlaneRbacState>>;
|
type DataPlaneRbacStore = UseStore<Partial<DataPlaneRbacState>>;
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
bulletList: {
|
||||||
|
listStyleType: "disc",
|
||||||
|
paddingLeft: "20px",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const useDataPlaneRbac: DataPlaneRbacStore = create(() => ({
|
export const useDataPlaneRbac: DataPlaneRbacStore = create(() => ({
|
||||||
dataPlaneRbacEnabled: false,
|
dataPlaneRbacEnabled: false,
|
||||||
}));
|
}));
|
||||||
@ -133,6 +144,9 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
|||||||
const [copilotSampleDBEnabled, setCopilotSampleDBEnabled] = useState<boolean>(
|
const [copilotSampleDBEnabled, setCopilotSampleDBEnabled] = useState<boolean>(
|
||||||
LocalStorageUtility.getEntryString(StorageKey.CopilotSampleDBEnabled) === "true",
|
LocalStorageUtility.getEntryString(StorageKey.CopilotSampleDBEnabled) === "true",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const styles = useStyles();
|
||||||
|
|
||||||
const explorerVersion = configContext.gitSha;
|
const explorerVersion = configContext.gitSha;
|
||||||
const shouldShowQueryPageOptions = userContext.apiType === "SQL";
|
const shouldShowQueryPageOptions = userContext.apiType === "SQL";
|
||||||
const shouldShowGraphAutoVizOption = userContext.apiType === "Gremlin";
|
const shouldShowGraphAutoVizOption = userContext.apiType === "Gremlin";
|
||||||
@ -153,43 +167,45 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
|||||||
|
|
||||||
LocalStorageUtility.setEntryNumber(StorageKey.CustomItemPerPage, customItemPerPage);
|
LocalStorageUtility.setEntryNumber(StorageKey.CustomItemPerPage, customItemPerPage);
|
||||||
|
|
||||||
LocalStorageUtility.setEntryString(StorageKey.DataPlaneRbacEnabled, enableDataPlaneRBACOption);
|
if (configContext.platform !== Platform.Fabric) {
|
||||||
if (
|
LocalStorageUtility.setEntryString(StorageKey.DataPlaneRbacEnabled, enableDataPlaneRBACOption);
|
||||||
enableDataPlaneRBACOption === Constants.RBACOptions.setTrueRBACOption ||
|
if (
|
||||||
(enableDataPlaneRBACOption === Constants.RBACOptions.setAutomaticRBACOption &&
|
enableDataPlaneRBACOption === Constants.RBACOptions.setTrueRBACOption ||
|
||||||
userContext.databaseAccount.properties.disableLocalAuth)
|
(enableDataPlaneRBACOption === Constants.RBACOptions.setAutomaticRBACOption &&
|
||||||
) {
|
userContext.databaseAccount.properties.disableLocalAuth)
|
||||||
updateUserContext({
|
) {
|
||||||
dataPlaneRbacEnabled: true,
|
updateUserContext({
|
||||||
hasDataPlaneRbacSettingChanged: true,
|
dataPlaneRbacEnabled: true,
|
||||||
});
|
hasDataPlaneRbacSettingChanged: true,
|
||||||
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: true });
|
});
|
||||||
} else {
|
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: true });
|
||||||
updateUserContext({
|
} else {
|
||||||
dataPlaneRbacEnabled: false,
|
updateUserContext({
|
||||||
hasDataPlaneRbacSettingChanged: true,
|
dataPlaneRbacEnabled: false,
|
||||||
});
|
hasDataPlaneRbacSettingChanged: true,
|
||||||
const { databaseAccount: account, subscriptionId, resourceGroup } = userContext;
|
});
|
||||||
if (!userContext.features.enableAadDataPlane && !userContext.masterKey) {
|
const { databaseAccount: account, subscriptionId, resourceGroup } = userContext;
|
||||||
let keys;
|
if (!userContext.features.enableAadDataPlane && !userContext.masterKey) {
|
||||||
try {
|
let keys;
|
||||||
keys = await listKeys(subscriptionId, resourceGroup, account.name);
|
try {
|
||||||
updateUserContext({
|
keys = await listKeys(subscriptionId, resourceGroup, account.name);
|
||||||
masterKey: keys.primaryMasterKey,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// if listKeys fail because of permissions issue, then make call to get ReadOnlyKeys
|
|
||||||
if (error.code === "AuthorizationFailed") {
|
|
||||||
keys = await getReadOnlyKeys(subscriptionId, resourceGroup, account.name);
|
|
||||||
updateUserContext({
|
updateUserContext({
|
||||||
masterKey: keys.primaryReadonlyMasterKey,
|
masterKey: keys.primaryMasterKey,
|
||||||
});
|
});
|
||||||
} else {
|
} catch (error) {
|
||||||
logConsoleError(`Error occurred fetching keys for the account." ${error.message}`);
|
// if listKeys fail because of permissions issue, then make call to get ReadOnlyKeys
|
||||||
throw error;
|
if (error.code === "AuthorizationFailed") {
|
||||||
|
keys = await getReadOnlyKeys(subscriptionId, resourceGroup, account.name);
|
||||||
|
updateUserContext({
|
||||||
|
masterKey: keys.primaryReadonlyMasterKey,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logConsoleError(`Error occurred fetching keys for the account." ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: false });
|
||||||
}
|
}
|
||||||
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: false });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -476,55 +492,57 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{userContext.apiType === "SQL" && userContext.authType === AuthType.AAD && (
|
{userContext.apiType === "SQL" &&
|
||||||
<>
|
userContext.authType === AuthType.AAD &&
|
||||||
<div className="settingsSection">
|
configContext.platform !== Platform.Fabric && (
|
||||||
<div className="settingsSectionPart">
|
<>
|
||||||
<fieldset>
|
<div className="settingsSection">
|
||||||
<legend id="enableDataPlaneRBACOptions" className="settingsSectionLabel legendLabel">
|
<div className="settingsSectionPart">
|
||||||
Enable Entra ID RBAC
|
<fieldset>
|
||||||
</legend>
|
<legend id="enableDataPlaneRBACOptions" className="settingsSectionLabel legendLabel">
|
||||||
<TooltipHost
|
Enable Entra ID RBAC
|
||||||
content={
|
</legend>
|
||||||
<>
|
<TooltipHost
|
||||||
Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable Entra
|
content={
|
||||||
ID RBAC.
|
<>
|
||||||
<a
|
Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable
|
||||||
href="https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac#use-data-explorer"
|
Entra ID RBAC.
|
||||||
target="_blank"
|
<a
|
||||||
rel="noopener noreferrer"
|
href="https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac#use-data-explorer"
|
||||||
>
|
target="_blank"
|
||||||
{" "}
|
rel="noopener noreferrer"
|
||||||
Learn more{" "}
|
>
|
||||||
</a>
|
{" "}
|
||||||
</>
|
Learn more{" "}
|
||||||
}
|
</a>
|
||||||
>
|
</>
|
||||||
<Icon iconName="Info" ariaLabel="Info tooltip" className="panelInfoIcon" tabIndex={0} />
|
}
|
||||||
</TooltipHost>
|
|
||||||
{showDataPlaneRBACWarning && configContext.platform === Platform.Portal && (
|
|
||||||
<MessageBar
|
|
||||||
messageBarType={MessageBarType.warning}
|
|
||||||
isMultiline={true}
|
|
||||||
onDismiss={() => setShowDataPlaneRBACWarning(false)}
|
|
||||||
dismissButtonAriaLabel="Close"
|
|
||||||
>
|
>
|
||||||
Please click on "Login for Entra ID RBAC" button prior to performing Entra ID RBAC
|
<Icon iconName="Info" ariaLabel="Info tooltip" className="panelInfoIcon" tabIndex={0} />
|
||||||
operations
|
</TooltipHost>
|
||||||
</MessageBar>
|
{showDataPlaneRBACWarning && configContext.platform === Platform.Portal && (
|
||||||
)}
|
<MessageBar
|
||||||
<ChoiceGroup
|
messageBarType={MessageBarType.warning}
|
||||||
ariaLabelledBy="enableDataPlaneRBACOptions"
|
isMultiline={true}
|
||||||
options={dataPlaneRBACOptionsList}
|
onDismiss={() => setShowDataPlaneRBACWarning(false)}
|
||||||
styles={choiceButtonStyles}
|
dismissButtonAriaLabel="Close"
|
||||||
selectedKey={enableDataPlaneRBACOption}
|
>
|
||||||
onChange={handleOnDataPlaneRBACOptionChange}
|
Please click on "Login for Entra ID RBAC" button prior to performing Entra ID RBAC
|
||||||
/>
|
operations
|
||||||
</fieldset>
|
</MessageBar>
|
||||||
|
)}
|
||||||
|
<ChoiceGroup
|
||||||
|
ariaLabelledBy="enableDataPlaneRBACOptions"
|
||||||
|
options={dataPlaneRBACOptionsList}
|
||||||
|
styles={choiceButtonStyles}
|
||||||
|
selectedKey={enableDataPlaneRBACOption}
|
||||||
|
onChange={handleOnDataPlaneRBACOptionChange}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
|
||||||
{userContext.apiType === "SQL" && (
|
{userContext.apiType === "SQL" && (
|
||||||
<>
|
<>
|
||||||
<div className="settingsSection">
|
<div className="settingsSection">
|
||||||
@ -830,6 +848,34 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="settingsSection">
|
||||||
|
<div className="settingsSectionPart">
|
||||||
|
<DefaultButton
|
||||||
|
onClick={() => {
|
||||||
|
useDialog.getState().showOkCancelModalDialog(
|
||||||
|
"Clear History",
|
||||||
|
undefined,
|
||||||
|
"Are you sure you want to proceed?",
|
||||||
|
() => deleteAllStates(),
|
||||||
|
"Cancel",
|
||||||
|
undefined,
|
||||||
|
<>
|
||||||
|
<span>
|
||||||
|
This action will clear the all customizations for this account in this browser, including:
|
||||||
|
</span>
|
||||||
|
<ul className={styles.bulletList}>
|
||||||
|
<li>Reset your customized tab layout, including the splitter positions</li>
|
||||||
|
<li>Erase your table column preferences, including any custom columns</li>
|
||||||
|
<li>Clear your filter history</li>
|
||||||
|
</ul>
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear History
|
||||||
|
</DefaultButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="settingsSection">
|
<div className="settingsSection">
|
||||||
<div className="settingsSectionPart">
|
<div className="settingsSectionPart">
|
||||||
<div className="settingsSectionLabel">Explorer Version</div>
|
<div className="settingsSectionLabel">Explorer Version</div>
|
||||||
|
@ -485,6 +485,19 @@ exports[`Settings Pane should render Default properly 1`] = `
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
className="settingsSection"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="settingsSectionPart"
|
||||||
|
>
|
||||||
|
<CustomizedDefaultButton
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
Clear History
|
||||||
|
</CustomizedDefaultButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className="settingsSection"
|
className="settingsSection"
|
||||||
>
|
>
|
||||||
@ -708,6 +721,19 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
className="settingsSection"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="settingsSectionPart"
|
||||||
|
>
|
||||||
|
<CustomizedDefaultButton
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
Clear History
|
||||||
|
</CustomizedDefaultButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className="settingsSection"
|
className="settingsSection"
|
||||||
>
|
>
|
||||||
|
100
src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts
Normal file
100
src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
// Definitions of State data
|
||||||
|
|
||||||
|
import { deleteState, loadState, saveState, saveStateDebounced } from "Shared/AppStatePersistenceUtility";
|
||||||
|
import { userContext } from "UserContext";
|
||||||
|
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||||
|
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
|
||||||
|
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
|
||||||
|
|
||||||
|
const componentName = "DocumentsTab";
|
||||||
|
export enum SubComponentName {
|
||||||
|
ColumnSizes = "ColumnSizes",
|
||||||
|
FilterHistory = "FilterHistory",
|
||||||
|
MainTabDivider = "MainTabDivider",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ColumnSizesMap = { [columnId: string]: WidthDefinition };
|
||||||
|
export type WidthDefinition = { idealWidth?: number; minWidth?: number };
|
||||||
|
export type TabDivider = { leftPaneWidthPercent: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param subComponentName
|
||||||
|
* @param collection
|
||||||
|
* @param defaultValue Will be returned if persisted state is not found
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const readSubComponentState = <T>(
|
||||||
|
subComponentName: SubComponentName,
|
||||||
|
collection: ViewModels.CollectionBase,
|
||||||
|
defaultValue: T,
|
||||||
|
): T => {
|
||||||
|
const globalAccountName = userContext.databaseAccount?.name;
|
||||||
|
if (!globalAccountName) {
|
||||||
|
const message = "Database account name not found in userContext";
|
||||||
|
console.error(message);
|
||||||
|
TelemetryProcessor.traceFailure(Action.ReadPersistedTabState, { message, componentName });
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = loadState({
|
||||||
|
componentName: componentName,
|
||||||
|
subComponentName,
|
||||||
|
globalAccountName,
|
||||||
|
databaseName: collection.databaseId,
|
||||||
|
containerName: collection.id(),
|
||||||
|
}) as T;
|
||||||
|
|
||||||
|
return state || defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param subComponentName
|
||||||
|
* @param collection
|
||||||
|
* @param state State to save
|
||||||
|
* @param debounce true for high-frequency calls (e.g mouse drag events)
|
||||||
|
*/
|
||||||
|
export const saveSubComponentState = <T>(
|
||||||
|
subComponentName: SubComponentName,
|
||||||
|
collection: ViewModels.CollectionBase,
|
||||||
|
state: T,
|
||||||
|
debounce?: boolean,
|
||||||
|
): void => {
|
||||||
|
const globalAccountName = userContext.databaseAccount?.name;
|
||||||
|
if (!globalAccountName) {
|
||||||
|
const message = "Database account name not found in userContext";
|
||||||
|
console.error(message);
|
||||||
|
TelemetryProcessor.traceFailure(Action.SavePersistedTabState, { message, componentName });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(debounce ? saveStateDebounced : saveState)(
|
||||||
|
{
|
||||||
|
componentName: componentName,
|
||||||
|
subComponentName,
|
||||||
|
globalAccountName,
|
||||||
|
databaseName: collection.databaseId,
|
||||||
|
containerName: collection.id(),
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteSubComponentState = (subComponentName: SubComponentName, collection: ViewModels.CollectionBase) => {
|
||||||
|
const globalAccountName = userContext.databaseAccount?.name;
|
||||||
|
if (!globalAccountName) {
|
||||||
|
const message = "Database account name not found in userContext";
|
||||||
|
console.error(message);
|
||||||
|
TelemetryProcessor.traceFailure(Action.DeletePersistedTabState, { message, componentName });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteState({
|
||||||
|
componentName: componentName,
|
||||||
|
subComponentName,
|
||||||
|
globalAccountName,
|
||||||
|
databaseName: collection.databaseId,
|
||||||
|
containerName: collection.id(),
|
||||||
|
});
|
||||||
|
};
|
@ -13,6 +13,7 @@ import {
|
|||||||
SAVE_BUTTON_ID,
|
SAVE_BUTTON_ID,
|
||||||
UPDATE_BUTTON_ID,
|
UPDATE_BUTTON_ID,
|
||||||
UPLOAD_BUTTON_ID,
|
UPLOAD_BUTTON_ID,
|
||||||
|
addStringsNoDuplicate,
|
||||||
buildQuery,
|
buildQuery,
|
||||||
getDiscardExistingDocumentChangesButtonState,
|
getDiscardExistingDocumentChangesButtonState,
|
||||||
getDiscardNewDocumentChangesButtonState,
|
getDiscardNewDocumentChangesButtonState,
|
||||||
@ -339,7 +340,10 @@ describe("Documents tab (noSql API)", () => {
|
|||||||
const createMockProps = (): IDocumentsTabComponentProps => ({
|
const createMockProps = (): IDocumentsTabComponentProps => ({
|
||||||
isPreferredApiMongoDB: false,
|
isPreferredApiMongoDB: false,
|
||||||
documentIds: [],
|
documentIds: [],
|
||||||
collection: undefined,
|
collection: {
|
||||||
|
id: ko.observable<string>("collectionId"),
|
||||||
|
databaseId: "databaseId",
|
||||||
|
} as ViewModels.CollectionBase,
|
||||||
partitionKey: { kind: "Hash", paths: ["/foo"], version: 2 },
|
partitionKey: { kind: "Hash", paths: ["/foo"], version: 2 },
|
||||||
onLoadStartKey: 0,
|
onLoadStartKey: 0,
|
||||||
tabTitle: "",
|
tabTitle: "",
|
||||||
@ -380,7 +384,7 @@ describe("Documents tab (noSql API)", () => {
|
|||||||
.findWhere((node) => node.text() === "Edit Filter")
|
.findWhere((node) => node.text() === "Edit Filter")
|
||||||
.at(0)
|
.at(0)
|
||||||
.simulate("click");
|
.simulate("click");
|
||||||
expect(wrapper.find("#filterInput").exists()).toBeTruthy();
|
expect(wrapper.find("Input.filterInput").exists()).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -474,3 +478,13 @@ describe("Documents tab (noSql API)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Documents tab", () => {
|
||||||
|
it("should add strings to array without duplicate", () => {
|
||||||
|
const array1 = ["a", "b", "c"];
|
||||||
|
const array2 = ["b", "c", "d"];
|
||||||
|
|
||||||
|
const array3 = addStringsNoDuplicate(array1, array2);
|
||||||
|
expect(array3).toEqual(["a", "b", "c", "d"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -20,6 +20,12 @@ import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
|
|||||||
import Explorer from "Explorer/Explorer";
|
import Explorer from "Explorer/Explorer";
|
||||||
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
||||||
import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities";
|
import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities";
|
||||||
|
import {
|
||||||
|
SubComponentName,
|
||||||
|
TabDivider,
|
||||||
|
readSubComponentState,
|
||||||
|
saveSubComponentState,
|
||||||
|
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
|
||||||
import { CosmosFluentProvider, LayoutConstants, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil";
|
import { CosmosFluentProvider, LayoutConstants, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil";
|
||||||
import { useSelectedNode } from "Explorer/useSelectedNode";
|
import { useSelectedNode } from "Explorer/useSelectedNode";
|
||||||
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
|
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
|
||||||
@ -51,6 +57,8 @@ import ObjectId from "../../Tree/ObjectId";
|
|||||||
import TabsBase from "../TabsBase";
|
import TabsBase from "../TabsBase";
|
||||||
import { DocumentsTableComponent, DocumentsTableComponentItem } from "./DocumentsTableComponent";
|
import { DocumentsTableComponent, DocumentsTableComponentItem } from "./DocumentsTableComponent";
|
||||||
|
|
||||||
|
const MAX_FILTER_HISTORY_COUNT = 100; // Datalist will become scrollable, so we can afford to keep more items than fit on the screen
|
||||||
|
|
||||||
const loadMoreHeight = LayoutConstants.rowHeight;
|
const loadMoreHeight = LayoutConstants.rowHeight;
|
||||||
export const useDocumentsTabStyles = makeStyles({
|
export const useDocumentsTabStyles = makeStyles({
|
||||||
container: {
|
container: {
|
||||||
@ -474,6 +482,24 @@ export const buildQuery = (
|
|||||||
return QueryUtils.buildDocumentsQuery(filter, partitionKeyProperties, partitionKey);
|
return QueryUtils.buildDocumentsQuery(filter, partitionKeyProperties, partitionKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export to expose to unit tests
|
||||||
|
*
|
||||||
|
* Add array2 to array1 without duplicates
|
||||||
|
* @param array1
|
||||||
|
* @param array2
|
||||||
|
* @return array1 with array2 added without duplicates
|
||||||
|
*/
|
||||||
|
export const addStringsNoDuplicate = (array1: string[], array2: string[]): string[] => {
|
||||||
|
const result = [...array1];
|
||||||
|
array2.forEach((item) => {
|
||||||
|
if (!result.includes(item)) {
|
||||||
|
result.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
// Export to expose to unit tests
|
// Export to expose to unit tests
|
||||||
export interface IDocumentsTabComponentProps {
|
export interface IDocumentsTabComponentProps {
|
||||||
isPreferredApiMongoDB: boolean;
|
isPreferredApiMongoDB: boolean;
|
||||||
@ -488,6 +514,11 @@ export interface IDocumentsTabComponentProps {
|
|||||||
isTabActive: boolean;
|
isTabActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getUniqueId = (collection: ViewModels.CollectionBase): string => `${collection.databaseId}-${collection.id()}`;
|
||||||
|
|
||||||
|
const defaultSqlFilters = ['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC'];
|
||||||
|
const defaultMongoFilters = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
|
||||||
|
|
||||||
// Export to expose to unit tests
|
// Export to expose to unit tests
|
||||||
export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabComponentProps> = ({
|
export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabComponentProps> = ({
|
||||||
isPreferredApiMongoDB,
|
isPreferredApiMongoDB,
|
||||||
@ -535,6 +566,13 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
ViewModels.DocumentExplorerState.noDocumentSelected,
|
ViewModels.DocumentExplorerState.noDocumentSelected,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [tabStateData, setTabStateData] = useState<TabDivider>(() =>
|
||||||
|
readSubComponentState(SubComponentName.MainTabDivider, _collection, {
|
||||||
|
leftPaneWidthPercent: 35,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const isQueryCopilotSampleContainer =
|
const isQueryCopilotSampleContainer =
|
||||||
_collection?.isSampleCollection &&
|
_collection?.isSampleCollection &&
|
||||||
_collection?.databaseId === QueryCopilotSampleDatabaseId &&
|
_collection?.databaseId === QueryCopilotSampleDatabaseId &&
|
||||||
@ -543,6 +581,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
// For Mongo only
|
// For Mongo only
|
||||||
const [continuationToken, setContinuationToken] = useState<string>(undefined);
|
const [continuationToken, setContinuationToken] = useState<string>(undefined);
|
||||||
|
|
||||||
|
// User's filter history
|
||||||
|
const [lastFilterContents, setLastFilterContents] = useState<string[]>(() =>
|
||||||
|
readSubComponentState(SubComponentName.FilterHistory, _collection, []),
|
||||||
|
);
|
||||||
|
|
||||||
const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB);
|
const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -568,8 +611,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
}
|
}
|
||||||
}, [documentIds, clickedRowIndex, editorState]);
|
}, [documentIds, clickedRowIndex, editorState]);
|
||||||
|
|
||||||
let lastFilterContents = ['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC'];
|
|
||||||
|
|
||||||
const applyFilterButton = {
|
const applyFilterButton = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
visible: true,
|
visible: true,
|
||||||
@ -1239,7 +1280,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
|
|
||||||
const onFilterKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
const onFilterKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
refreshDocumentsGrid(true);
|
onApplyFilterClick();
|
||||||
|
|
||||||
// Suppress the default behavior of the key
|
// Suppress the default behavior of the key
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -1442,7 +1483,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
return partitionKey;
|
return partitionKey;
|
||||||
};
|
};
|
||||||
|
|
||||||
lastFilterContents = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
|
|
||||||
partitionKeyProperties = partitionKeyProperties?.map((partitionKeyProperty, i) => {
|
partitionKeyProperties = partitionKeyProperties?.map((partitionKeyProperty, i) => {
|
||||||
if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) {
|
if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) {
|
||||||
partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, "");
|
partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, "");
|
||||||
@ -1663,6 +1703,24 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
}
|
}
|
||||||
// ***************** Mongo ***************************
|
// ***************** Mongo ***************************
|
||||||
|
|
||||||
|
const onApplyFilterClick = (): void => {
|
||||||
|
refreshDocumentsGrid(true);
|
||||||
|
|
||||||
|
// Remove duplicates, but keep order
|
||||||
|
if (lastFilterContents.includes(filterContent)) {
|
||||||
|
lastFilterContents.splice(lastFilterContents.indexOf(filterContent), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save filter content to local storage
|
||||||
|
lastFilterContents.unshift(filterContent);
|
||||||
|
|
||||||
|
// Keep the list size under MAX_FILTER_HISTORY_COUNT. Drop last element if needed.
|
||||||
|
const limitedLastFilterContents = lastFilterContents.slice(0, MAX_FILTER_HISTORY_COUNT);
|
||||||
|
|
||||||
|
setLastFilterContents(limitedLastFilterContents);
|
||||||
|
saveSubComponentState(SubComponentName.FilterHistory, _collection, lastFilterContents);
|
||||||
|
};
|
||||||
|
|
||||||
const refreshDocumentsGrid = useCallback(
|
const refreshDocumentsGrid = useCallback(
|
||||||
(applyFilterButtonPressed: boolean): void => {
|
(applyFilterButtonPressed: boolean): void => {
|
||||||
// clear documents grid
|
// clear documents grid
|
||||||
@ -1721,12 +1779,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
<div className={styles.filterRow}>
|
<div className={styles.filterRow}>
|
||||||
{!isPreferredApiMongoDB && <span> SELECT * FROM c </span>}
|
{!isPreferredApiMongoDB && <span> SELECT * FROM c </span>}
|
||||||
<Input
|
<Input
|
||||||
id="filterInput"
|
|
||||||
ref={filterInput}
|
ref={filterInput}
|
||||||
type="text"
|
type="text"
|
||||||
size="small"
|
size="small"
|
||||||
list="filtersList"
|
list={`filtersList-${getUniqueId(_collection)}`}
|
||||||
className={styles.filterInput}
|
className={`filterInput ${styles.filterInput}`}
|
||||||
title="Type a query predicate or choose one from the list."
|
title="Type a query predicate or choose one from the list."
|
||||||
placeholder={
|
placeholder={
|
||||||
isPreferredApiMongoDB
|
isPreferredApiMongoDB
|
||||||
@ -1740,8 +1797,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
onBlur={() => setIsFilterFocused(false)}
|
onBlur={() => setIsFilterFocused(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<datalist id="filtersList">
|
<datalist id={`filtersList-${getUniqueId(_collection)}`}>
|
||||||
{lastFilterContents.map((filter) => (
|
{addStringsNoDuplicate(
|
||||||
|
lastFilterContents,
|
||||||
|
isPreferredApiMongoDB ? defaultMongoFilters : defaultSqlFilters,
|
||||||
|
).map((filter) => (
|
||||||
<option key={filter} value={filter} />
|
<option key={filter} value={filter} />
|
||||||
))}
|
))}
|
||||||
</datalist>
|
</datalist>
|
||||||
@ -1749,7 +1809,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
<Button
|
<Button
|
||||||
appearance="primary"
|
appearance="primary"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => refreshDocumentsGrid(true)}
|
onClick={onApplyFilterClick}
|
||||||
disabled={!applyFilterButton.enabled}
|
disabled={!applyFilterButton.enabled}
|
||||||
aria-label="Apply filter"
|
aria-label="Apply filter"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@ -1780,11 +1840,16 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* <Split> doesn't like to be a flex child */}
|
{/* <Split> doesn't like to be a flex child */}
|
||||||
<div style={{ overflow: "hidden", height: "100%" }}>
|
<div style={{ overflow: "hidden", height: "100%" }}>
|
||||||
<Allotment>
|
<Allotment
|
||||||
<Allotment.Pane preferredSize="35%" minSize={175}>
|
onDragEnd={(sizes: number[]) => {
|
||||||
|
tabStateData.leftPaneWidthPercent = (100 * sizes[0]) / (sizes[0] + sizes[1]);
|
||||||
|
saveSubComponentState(SubComponentName.MainTabDivider, _collection, tabStateData);
|
||||||
|
setTabStateData(tabStateData);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Allotment.Pane preferredSize={`${tabStateData.leftPaneWidthPercent}%`} minSize={55}>
|
||||||
<div style={{ height: "100%", width: "100%", overflow: "hidden" }} ref={tableContainerRef}>
|
<div style={{ height: "100%", width: "100%", overflow: "hidden" }} ref={tableContainerRef}>
|
||||||
<div className={styles.floatingControlsContainer}>
|
<div className={styles.floatingControlsContainer}>
|
||||||
<div className={styles.floatingControls}>
|
<div className={styles.floatingControls}>
|
||||||
@ -1813,6 +1878,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
(partitionKey.systemKey && !isPreferredApiMongoDB) ||
|
(partitionKey.systemKey && !isPreferredApiMongoDB) ||
|
||||||
(configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly)
|
(configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly)
|
||||||
}
|
}
|
||||||
|
collection={_collection}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{tableItems.length > 0 && (
|
{tableItems.length > 0 && (
|
||||||
@ -1828,7 +1894,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Allotment.Pane>
|
</Allotment.Pane>
|
||||||
<Allotment.Pane preferredSize="65%" minSize={300}>
|
<Allotment.Pane minSize={30}>
|
||||||
<div style={{ height: "100%", width: "100%" }}>
|
<div style={{ height: "100%", width: "100%" }}>
|
||||||
{isTabActive && selectedDocumentContent && selectedRows.size <= 1 && (
|
{isTabActive && selectedDocumentContent && selectedRows.size <= 1 && (
|
||||||
<EditorReact
|
<EditorReact
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { TableRowId } from "@fluentui/react-components";
|
import { TableRowId } from "@fluentui/react-components";
|
||||||
import { mount } from "enzyme";
|
import { mount } from "enzyme";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||||
import { DocumentsTableComponent, IDocumentsTableComponentProps } from "./DocumentsTableComponent";
|
import { DocumentsTableComponent, IDocumentsTableComponentProps } from "./DocumentsTableComponent";
|
||||||
|
|
||||||
const PARTITION_KEY_HEADER = "partitionKey";
|
const PARTITION_KEY_HEADER = "partitionKey";
|
||||||
@ -25,6 +26,10 @@ describe("DocumentsTableComponent", () => {
|
|||||||
partitionKeyHeaders: [PARTITION_KEY_HEADER],
|
partitionKeyHeaders: [PARTITION_KEY_HEADER],
|
||||||
},
|
},
|
||||||
isSelectionDisabled: false,
|
isSelectionDisabled: false,
|
||||||
|
collection: {
|
||||||
|
databaseId: "db",
|
||||||
|
id: ((): string => "coll") as ko.Observable<string>,
|
||||||
|
} as ViewModels.CollectionBase,
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render documents and partition keys in header", () => {
|
it("should render documents and partition keys in header", () => {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
createTableColumn,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
MenuList,
|
MenuList,
|
||||||
@ -16,19 +17,26 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
TableRowId,
|
TableRowId,
|
||||||
TableSelectionCell,
|
TableSelectionCell,
|
||||||
createTableColumn,
|
|
||||||
useArrowNavigationGroup,
|
useArrowNavigationGroup,
|
||||||
useTableColumnSizing_unstable,
|
useTableColumnSizing_unstable,
|
||||||
useTableFeatures,
|
useTableFeatures,
|
||||||
useTableSelection,
|
useTableSelection,
|
||||||
} from "@fluentui/react-components";
|
} from "@fluentui/react-components";
|
||||||
import { NormalizedEventKey } from "Common/Constants";
|
import { NormalizedEventKey } from "Common/Constants";
|
||||||
|
import {
|
||||||
|
ColumnSizesMap,
|
||||||
|
readSubComponentState,
|
||||||
|
saveSubComponentState,
|
||||||
|
SubComponentName,
|
||||||
|
WidthDefinition,
|
||||||
|
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
|
||||||
import { INITIAL_SELECTED_ROW_INDEX, useDocumentsTabStyles } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
|
import { INITIAL_SELECTED_ROW_INDEX, useDocumentsTabStyles } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
|
||||||
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
|
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
|
||||||
import { LayoutConstants } from "Explorer/Theme/ThemeUtil";
|
import { LayoutConstants } from "Explorer/Theme/ThemeUtil";
|
||||||
import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils";
|
import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { FixedSizeList as List, ListChildComponentProps } from "react-window";
|
import { FixedSizeList as List, ListChildComponentProps } from "react-window";
|
||||||
|
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||||
|
|
||||||
export type DocumentsTableComponentItem = {
|
export type DocumentsTableComponentItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -47,6 +55,7 @@ export interface IDocumentsTableComponentProps {
|
|||||||
columnHeaders: ColumnHeaders;
|
columnHeaders: ColumnHeaders;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
isSelectionDisabled?: boolean;
|
isSelectionDisabled?: boolean;
|
||||||
|
collection: ViewModels.CollectionBase;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TableRowData extends RowStateBase<DocumentsTableComponentItem> {
|
interface TableRowData extends RowStateBase<DocumentsTableComponentItem> {
|
||||||
@ -59,6 +68,11 @@ interface ReactWindowRenderFnProps extends ListChildComponentProps {
|
|||||||
data: TableRowData[];
|
data: TableRowData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultSize: WidthDefinition = {
|
||||||
|
idealWidth: 200,
|
||||||
|
minWidth: 50,
|
||||||
|
};
|
||||||
|
|
||||||
export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({
|
export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({
|
||||||
items,
|
items,
|
||||||
onSelectedRowsChange,
|
onSelectedRowsChange,
|
||||||
@ -67,32 +81,34 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
|
|||||||
size,
|
size,
|
||||||
columnHeaders,
|
columnHeaders,
|
||||||
isSelectionDisabled,
|
isSelectionDisabled,
|
||||||
|
collection,
|
||||||
}: IDocumentsTableComponentProps) => {
|
}: IDocumentsTableComponentProps) => {
|
||||||
const styles = useDocumentsTabStyles();
|
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(() => {
|
||||||
|
const columnIds = ["id"].concat(columnHeaders.partitionKeyHeaders);
|
||||||
const initialSizingOptions: TableColumnSizingOptions = {
|
const columnSizesMap: ColumnSizesMap = readSubComponentState(SubComponentName.ColumnSizes, collection, {});
|
||||||
id: {
|
const columnSizesPx: ColumnSizesMap = {};
|
||||||
idealWidth: 280,
|
columnIds.forEach((columnId) => {
|
||||||
minWidth: 50,
|
columnSizesPx[columnId] = (columnSizesMap && columnSizesMap[columnId]) || defaultSize;
|
||||||
},
|
});
|
||||||
};
|
return columnSizesPx;
|
||||||
columnHeaders.partitionKeyHeaders.forEach((pkHeader) => {
|
|
||||||
initialSizingOptions[pkHeader] = {
|
|
||||||
idealWidth: 200,
|
|
||||||
minWidth: 50,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(initialSizingOptions);
|
const styles = useDocumentsTabStyles();
|
||||||
|
|
||||||
const onColumnResize = React.useCallback((_, { columnId, width }) => {
|
const onColumnResize = React.useCallback((_, { columnId, width }) => {
|
||||||
setColumnSizingOptions((state) => ({
|
setColumnSizingOptions((state) => {
|
||||||
...state,
|
const newSizingOptions = {
|
||||||
[columnId]: {
|
...state,
|
||||||
...state[columnId],
|
[columnId]: {
|
||||||
idealWidth: width,
|
...state[columnId],
|
||||||
},
|
idealWidth: width,
|
||||||
}));
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
saveSubComponentState(SubComponentName.ColumnSizes, collection, newSizingOptions, true);
|
||||||
|
|
||||||
|
return newSizingOptions;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Columns must be a static object and cannot change on re-renders otherwise React will complain about too many refreshes
|
// Columns must be a static object and cannot change on re-renders otherwise React will complain about too many refreshes
|
||||||
|
@ -38,9 +38,11 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Allotment>
|
<Allotment
|
||||||
|
onDragEnd={[Function]}
|
||||||
|
>
|
||||||
<Allotment.Pane
|
<Allotment.Pane
|
||||||
minSize={175}
|
minSize={55}
|
||||||
preferredSize="35%"
|
preferredSize="35%"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -77,6 +79,12 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
|||||||
className="___9o87uj0_0000000 ffefeo0"
|
className="___9o87uj0_0000000 ffefeo0"
|
||||||
>
|
>
|
||||||
<DocumentsTableComponent
|
<DocumentsTableComponent
|
||||||
|
collection={
|
||||||
|
{
|
||||||
|
"databaseId": "databaseId",
|
||||||
|
"id": [Function],
|
||||||
|
}
|
||||||
|
}
|
||||||
columnHeaders={
|
columnHeaders={
|
||||||
{
|
{
|
||||||
"idHeader": "id",
|
"idHeader": "id",
|
||||||
@ -97,8 +105,7 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
</Allotment.Pane>
|
</Allotment.Pane>
|
||||||
<Allotment.Pane
|
<Allotment.Pane
|
||||||
minSize={300}
|
minSize={30}
|
||||||
preferredSize="65%"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={
|
style={
|
||||||
|
@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
exports[`DocumentsTableComponent should not render selection column when isSelectionDisabled is true 1`] = `
|
exports[`DocumentsTableComponent should not render selection column when isSelectionDisabled is true 1`] = `
|
||||||
<DocumentsTableComponent
|
<DocumentsTableComponent
|
||||||
|
collection={
|
||||||
|
{
|
||||||
|
"databaseId": "db",
|
||||||
|
"id": [Function],
|
||||||
|
}
|
||||||
|
}
|
||||||
columnHeaders={
|
columnHeaders={
|
||||||
{
|
{
|
||||||
"idHeader": "id",
|
"idHeader": "id",
|
||||||
@ -995,6 +1001,12 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
|
|||||||
|
|
||||||
exports[`DocumentsTableComponent should render documents and partition keys in header 1`] = `
|
exports[`DocumentsTableComponent should render documents and partition keys in header 1`] = `
|
||||||
<DocumentsTableComponent
|
<DocumentsTableComponent
|
||||||
|
collection={
|
||||||
|
{
|
||||||
|
"databaseId": "db",
|
||||||
|
"id": [Function],
|
||||||
|
}
|
||||||
|
}
|
||||||
columnHeaders={
|
columnHeaders={
|
||||||
{
|
{
|
||||||
"idHeader": "id",
|
"idHeader": "id",
|
||||||
|
170
src/Shared/AppStatePersistenceUtility.test.ts
Normal file
170
src/Shared/AppStatePersistenceUtility.test.ts
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import { createKeyFromPath, deleteState, loadState, MAX_ENTRY_NB, saveState } from "Shared/AppStatePersistenceUtility";
|
||||||
|
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
|
||||||
|
|
||||||
|
jest.mock("Shared/StorageUtility", () => ({
|
||||||
|
LocalStorageUtility: {
|
||||||
|
getEntryObject: jest.fn(),
|
||||||
|
setEntryObject: jest.fn(),
|
||||||
|
},
|
||||||
|
StorageKey: {
|
||||||
|
AppState: "AppState",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("AppStatePersistenceUtility", () => {
|
||||||
|
const storePath = {
|
||||||
|
componentName: "a",
|
||||||
|
subComponentName: "b",
|
||||||
|
globalAccountName: "c",
|
||||||
|
databaseName: "d",
|
||||||
|
containerName: "e",
|
||||||
|
};
|
||||||
|
const key = createKeyFromPath(storePath);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({
|
||||||
|
key0: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
timestamp: 0,
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saveState()", () => {
|
||||||
|
const testState = { aa: 1, bb: "2", cc: [3, 4] };
|
||||||
|
|
||||||
|
it("should save state", () => {
|
||||||
|
saveState(storePath, testState);
|
||||||
|
expect(LocalStorageUtility.setEntryObject).toHaveBeenCalledTimes(1);
|
||||||
|
expect(LocalStorageUtility.setEntryObject).toHaveBeenCalledWith(StorageKey.AppState, expect.any(Object));
|
||||||
|
|
||||||
|
const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
|
||||||
|
expect(passedState[key].data).toHaveProperty("aa", 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should save state with timestamp", () => {
|
||||||
|
saveState(storePath, testState);
|
||||||
|
const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
|
||||||
|
expect(passedState[key]).toHaveProperty("timestamp");
|
||||||
|
expect(passedState[key].timestamp).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add state to existing state", () => {
|
||||||
|
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({
|
||||||
|
key0: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
timestamp: 0,
|
||||||
|
data: { dd: 5 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
saveState(storePath, testState);
|
||||||
|
const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
|
||||||
|
expect(passedState["key0"].data).toHaveProperty("dd", 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove the oldest entry when the number of entries exceeds the limit", () => {
|
||||||
|
// Fill up storage with MAX entries
|
||||||
|
const currentAppState = {};
|
||||||
|
for (let i = 0; i < MAX_ENTRY_NB; i++) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(currentAppState as any)[`key${i}`] = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
timestamp: i,
|
||||||
|
data: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue(currentAppState);
|
||||||
|
|
||||||
|
saveState(storePath, testState);
|
||||||
|
|
||||||
|
// Verify that the new entry is saved
|
||||||
|
const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
|
||||||
|
expect(passedState[key].data).toHaveProperty("aa", 1);
|
||||||
|
|
||||||
|
// Verify that the oldest entry is removed (smallest timestamp)
|
||||||
|
const passedAppState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
|
||||||
|
expect(Object.keys(passedAppState).length).toBe(MAX_ENTRY_NB);
|
||||||
|
expect(passedAppState).not.toHaveProperty("key0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not remove the oldest entry when the number of entries does not exceed the limit", () => {
|
||||||
|
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({
|
||||||
|
key0: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
timestamp: 0,
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
key1: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
timestamp: 1,
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
saveState(storePath, testState);
|
||||||
|
const passedAppState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
|
||||||
|
expect(Object.keys(passedAppState).length).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadState()", () => {
|
||||||
|
it("should load state", () => {
|
||||||
|
const data = { aa: 1, bb: "2", cc: [3, 4] };
|
||||||
|
const testState = {
|
||||||
|
[key]: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
timestamp: 0,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue(testState);
|
||||||
|
const state = loadState(storePath);
|
||||||
|
expect(state).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return undefined if the state is not found", () => {
|
||||||
|
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue(null);
|
||||||
|
const state = loadState(storePath);
|
||||||
|
expect(state).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteState()", () => {
|
||||||
|
it("should delete state", () => {
|
||||||
|
const key = createKeyFromPath(storePath);
|
||||||
|
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({
|
||||||
|
[key]: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
timestamp: 0,
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
otherKey: {
|
||||||
|
schemaVersion: 2,
|
||||||
|
timestamp: 0,
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteState(storePath);
|
||||||
|
expect(LocalStorageUtility.setEntryObject).toHaveBeenCalledTimes(1);
|
||||||
|
const passedAppState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
|
||||||
|
expect(passedAppState).not.toHaveProperty(key);
|
||||||
|
expect(passedAppState).toHaveProperty("otherKey");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("createKeyFromPath()", () => {
|
||||||
|
it("should create path that contains all components", () => {
|
||||||
|
const key = createKeyFromPath(storePath);
|
||||||
|
expect(key).toContain(storePath.componentName);
|
||||||
|
expect(key).toContain(storePath.subComponentName);
|
||||||
|
expect(key).toContain(storePath.globalAccountName);
|
||||||
|
expect(key).toContain(storePath.databaseName);
|
||||||
|
expect(key).toContain(storePath.containerName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
109
src/Shared/AppStatePersistenceUtility.ts
Normal file
109
src/Shared/AppStatePersistenceUtility.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
|
||||||
|
|
||||||
|
// The component name whose state is being saved. Component name must not include special characters.
|
||||||
|
export type ComponentName = "DocumentsTab";
|
||||||
|
|
||||||
|
const SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
|
// Export for testing purposes
|
||||||
|
export const MAX_ENTRY_NB = 100_000; // Limit number of entries to 100k
|
||||||
|
|
||||||
|
export interface StateData {
|
||||||
|
schemaVersion: number;
|
||||||
|
timestamp: number;
|
||||||
|
data: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorePath = {
|
||||||
|
componentName: string;
|
||||||
|
subComponentName?: string;
|
||||||
|
globalAccountName?: string;
|
||||||
|
databaseName?: string;
|
||||||
|
containerName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load and save state data
|
||||||
|
export const loadState = (path: StorePath): unknown => {
|
||||||
|
const appState =
|
||||||
|
LocalStorageUtility.getEntryObject<ApplicationState>(StorageKey.AppState) || ({} as ApplicationState);
|
||||||
|
const key = createKeyFromPath(path);
|
||||||
|
return appState[key]?.data;
|
||||||
|
};
|
||||||
|
export const saveState = (path: StorePath, state: unknown): void => {
|
||||||
|
// Retrieve state object
|
||||||
|
const appState =
|
||||||
|
LocalStorageUtility.getEntryObject<ApplicationState>(StorageKey.AppState) || ({} as ApplicationState);
|
||||||
|
const key = createKeyFromPath(path);
|
||||||
|
appState[key] = {
|
||||||
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
data: state,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Object.keys(appState).length > MAX_ENTRY_NB) {
|
||||||
|
// Remove the oldest entry
|
||||||
|
const oldestKey = Object.keys(appState).reduce((oldest, current) =>
|
||||||
|
appState[current].timestamp < appState[oldest].timestamp ? current : oldest,
|
||||||
|
);
|
||||||
|
delete appState[oldestKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalStorageUtility.setEntryObject(StorageKey.AppState, appState);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteState = (path: StorePath): void => {
|
||||||
|
// Retrieve state object
|
||||||
|
const appState =
|
||||||
|
LocalStorageUtility.getEntryObject<ApplicationState>(StorageKey.AppState) || ({} as ApplicationState);
|
||||||
|
const key = createKeyFromPath(path);
|
||||||
|
delete appState[key];
|
||||||
|
LocalStorageUtility.setEntryObject(StorageKey.AppState, appState);
|
||||||
|
};
|
||||||
|
|
||||||
|
// This is for high-frequency state changes
|
||||||
|
let timeoutId: NodeJS.Timeout | undefined;
|
||||||
|
export const saveStateDebounced = (path: StorePath, state: unknown, debounceDelayMs = 1000): void => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
timeoutId = setTimeout(() => saveState(path, state), debounceDelayMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ApplicationState {
|
||||||
|
[statePath: string]: StateData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedPathSegments: (keyof StorePath)[] = [
|
||||||
|
"subComponentName",
|
||||||
|
"globalAccountName",
|
||||||
|
"databaseName",
|
||||||
|
"containerName",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /componentName/subComponentName/globalAccountName/databaseName/containerName/
|
||||||
|
* Any of the path segments can be "" except componentName
|
||||||
|
* Export for testing purposes
|
||||||
|
* @param path
|
||||||
|
*/
|
||||||
|
export const createKeyFromPath = (path: StorePath): string => {
|
||||||
|
if (path.componentName.includes("/")) {
|
||||||
|
throw new Error(`Invalid component name: ${path.componentName}`);
|
||||||
|
}
|
||||||
|
let key = `/${path.componentName}`; // ComponentName is always there
|
||||||
|
orderedPathSegments.forEach((segment) => {
|
||||||
|
const segmentValue = path[segment as keyof StorePath];
|
||||||
|
if (segmentValue.includes("/")) {
|
||||||
|
throw new Error(`Invalid setting path segment: ${segment}`);
|
||||||
|
}
|
||||||
|
key += `/${segmentValue !== undefined ? segmentValue : ""}`;
|
||||||
|
});
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the entire app state key from local storage
|
||||||
|
*/
|
||||||
|
export const deleteAllStates = (): void => {
|
||||||
|
LocalStorageUtility.removeEntry(StorageKey.AppState);
|
||||||
|
};
|
@ -20,3 +20,14 @@ export const setEntryNumber = (key: StorageKey, value: number): void =>
|
|||||||
|
|
||||||
export const setEntryBoolean = (key: StorageKey, value: boolean): void =>
|
export const setEntryBoolean = (key: StorageKey, value: boolean): void =>
|
||||||
localStorage.setItem(StorageKey[key], value.toString());
|
localStorage.setItem(StorageKey[key], value.toString());
|
||||||
|
|
||||||
|
export const setEntryObject = (key: StorageKey, value: unknown): void => {
|
||||||
|
localStorage.setItem(StorageKey[key], JSON.stringify(value));
|
||||||
|
};
|
||||||
|
export const getEntryObject = <T>(key: StorageKey): T | null => {
|
||||||
|
const item = localStorage.getItem(StorageKey[key]);
|
||||||
|
if (item) {
|
||||||
|
return JSON.parse(item) as T;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
@ -30,6 +30,7 @@ export enum StorageKey {
|
|||||||
VisitedAccounts,
|
VisitedAccounts,
|
||||||
PriorityLevel,
|
PriorityLevel,
|
||||||
DefaultQueryResultsView,
|
DefaultQueryResultsView,
|
||||||
|
AppState,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const hasRUThresholdBeenConfigured = (): boolean => {
|
export const hasRUThresholdBeenConfigured = (): boolean => {
|
||||||
|
@ -139,6 +139,9 @@ export enum Action {
|
|||||||
QueryEdited,
|
QueryEdited,
|
||||||
ExecuteQueryGeneratedFromQueryCopilot,
|
ExecuteQueryGeneratedFromQueryCopilot,
|
||||||
DeleteDocuments,
|
DeleteDocuments,
|
||||||
|
ReadPersistedTabState,
|
||||||
|
SavePersistedTabState,
|
||||||
|
DeletePersistedTabState,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ActionModifiers = {
|
export const ActionModifiers = {
|
||||||
|
Loading…
Reference in New Issue
Block a user