mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-01-05 18:47:41 +00:00
Compare commits
7 Commits
cloudshell
...
users/lang
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88e47403d8 | ||
|
|
582d581edf | ||
|
|
f65e7ab1d6 | ||
|
|
bf1474e101 | ||
|
|
4fda518a3f | ||
|
|
9aa55aa4e5 | ||
|
|
96b2ef728a |
68
src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts
Normal file
68
src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// Definitions of State data
|
||||||
|
|
||||||
|
import { loadState, saveState, saveStateDebounced } from "Shared/AppStatePersistenceUtility";
|
||||||
|
import { userContext } from "UserContext";
|
||||||
|
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||||
|
|
||||||
|
// Component states
|
||||||
|
export interface DocumentsTabStateData {
|
||||||
|
leftPaneWidthPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultState: DocumentsTabStateData = {
|
||||||
|
leftPaneWidthPercent: 35,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ComponentName = "DocumentsTab";
|
||||||
|
|
||||||
|
export const readDocumentsTabState = (): DocumentsTabStateData => {
|
||||||
|
const state = loadState({ componentName: ComponentName });
|
||||||
|
return (state as DocumentsTabStateData) || defaultState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveDocumentsTabState = (state: DocumentsTabStateData): void => {
|
||||||
|
saveStateDebounced({ componentName: ComponentName }, state);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ColumnSizesMap = { [columnId: string]: WidthDefinition };
|
||||||
|
export type WidthDefinition = { idealWidth?: number; minWidth?: number };
|
||||||
|
|
||||||
|
export const readSubComponentState = <T>(
|
||||||
|
subComponentName: "ColumnSizes" | "FilterHistory",
|
||||||
|
collection: ViewModels.CollectionBase,
|
||||||
|
defaultValue: T,
|
||||||
|
): T => {
|
||||||
|
const globalAccountName = userContext.databaseAccount?.name;
|
||||||
|
// TODO what if databaseAccount doesn't exist?
|
||||||
|
|
||||||
|
const state = loadState({
|
||||||
|
componentName: ComponentName,
|
||||||
|
subComponentName,
|
||||||
|
globalAccountName,
|
||||||
|
databaseName: collection.databaseId,
|
||||||
|
containerName: collection.id(),
|
||||||
|
}) as T;
|
||||||
|
|
||||||
|
return state || defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveSubComponentState = <T>(
|
||||||
|
subComponentName: "ColumnSizes" | "FilterHistory",
|
||||||
|
collection: ViewModels.CollectionBase,
|
||||||
|
state: T,
|
||||||
|
debounce?: boolean,
|
||||||
|
): void => {
|
||||||
|
const globalAccountName = userContext.databaseAccount?.name;
|
||||||
|
// TODO what if databaseAccount doesn't exist?
|
||||||
|
|
||||||
|
(debounce ? saveStateDebounced : saveState)(
|
||||||
|
{
|
||||||
|
componentName: ComponentName,
|
||||||
|
subComponentName,
|
||||||
|
globalAccountName,
|
||||||
|
databaseName: collection.databaseId,
|
||||||
|
containerName: collection.id(),
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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: "",
|
||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { Item, ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos";
|
import { Item, ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos";
|
||||||
import { Button, FluentProvider, Input, TableRowId } from "@fluentui/react-components";
|
import {
|
||||||
|
Button,
|
||||||
|
FluentProvider,
|
||||||
|
Input,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
MenuList,
|
||||||
|
MenuPopover,
|
||||||
|
MenuProps,
|
||||||
|
PositioningImperativeRef,
|
||||||
|
TableRowId,
|
||||||
|
useRestoreFocusTarget,
|
||||||
|
} from "@fluentui/react-components";
|
||||||
import { ArrowClockwise16Filled, Dismiss16Filled } from "@fluentui/react-icons";
|
import { ArrowClockwise16Filled, Dismiss16Filled } from "@fluentui/react-icons";
|
||||||
import Split from "@uiw/react-split";
|
import Split from "@uiw/react-split";
|
||||||
import { KeyCodes, QueryCopilotSampleContainerId, QueryCopilotSampleDatabaseId } from "Common/Constants";
|
import { KeyCodes, QueryCopilotSampleContainerId, QueryCopilotSampleDatabaseId } from "Common/Constants";
|
||||||
@@ -21,6 +33,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 {
|
||||||
|
DocumentsTabStateData,
|
||||||
|
readDocumentsTabState,
|
||||||
|
readSubComponentState,
|
||||||
|
saveSubComponentState,
|
||||||
|
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
|
||||||
import { getPlatformTheme } from "Explorer/Theme/ThemeUtil";
|
import { getPlatformTheme } 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";
|
||||||
@@ -420,6 +438,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;
|
||||||
@@ -434,6 +470,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,
|
||||||
@@ -480,6 +521,9 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
ViewModels.DocumentExplorerState.noDocumentSelected,
|
ViewModels.DocumentExplorerState.noDocumentSelected,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [tabStateData, setTabStateData] = useState<DocumentsTabStateData>(() => readDocumentsTabState());
|
||||||
|
|
||||||
const isQueryCopilotSampleContainer =
|
const isQueryCopilotSampleContainer =
|
||||||
_collection?.isSampleCollection &&
|
_collection?.isSampleCollection &&
|
||||||
_collection?.databaseId === QueryCopilotSampleDatabaseId &&
|
_collection?.databaseId === QueryCopilotSampleDatabaseId &&
|
||||||
@@ -488,6 +532,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("FilterHistory", _collection, []),
|
||||||
|
);
|
||||||
|
|
||||||
const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB);
|
const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -513,8 +562,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,
|
||||||
@@ -1303,6 +1350,26 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
return () => resizeObserver.disconnect(); // clean up
|
return () => resizeObserver.disconnect(); // clean up
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [filterMenuOpen, setFilterMenuOpen] = React.useState(false);
|
||||||
|
const onFilterMenuOpenChange: MenuProps["onOpenChange"] = (e, data) => {
|
||||||
|
// do not close menu as an outside click if clicking on the custom trigger/target
|
||||||
|
// this prevents it from closing & immediately re-opening when clicking custom triggers
|
||||||
|
if (data.type === "clickOutside" && (e.target === filterInput.current || e.target === inputRef.current)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFilterMenuOpen(data.open);
|
||||||
|
};
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const positioningRef = React.useRef<PositioningImperativeRef>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (filterInput.current) {
|
||||||
|
positioningRef.current?.setTarget(filterInput.current);
|
||||||
|
}
|
||||||
|
}, [filterInput, positioningRef]);
|
||||||
|
|
||||||
|
const restoreFocusTargetAttribute = useRestoreFocusTarget();
|
||||||
|
|
||||||
const columnHeaders = {
|
const columnHeaders = {
|
||||||
idHeader: isPreferredApiMongoDB ? "_id" : "id",
|
idHeader: isPreferredApiMongoDB ? "_id" : "id",
|
||||||
partitionKeyHeaders: (showPartitionKey(_collection, isPreferredApiMongoDB) && partitionKeyPropertyHeaders) || [],
|
partitionKeyHeaders: (showPartitionKey(_collection, isPreferredApiMongoDB) && partitionKeyPropertyHeaders) || [],
|
||||||
@@ -1369,7 +1436,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, "");
|
||||||
@@ -1646,6 +1712,20 @@ 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);
|
||||||
|
setLastFilterContents([...lastFilterContents]);
|
||||||
|
saveSubComponentState("FilterHistory", _collection, lastFilterContents);
|
||||||
|
};
|
||||||
|
|
||||||
const refreshDocumentsGrid = useCallback(
|
const refreshDocumentsGrid = useCallback(
|
||||||
(applyFilterButtonPressed: boolean): void => {
|
(applyFilterButtonPressed: boolean): void => {
|
||||||
// clear documents grid
|
// clear documents grid
|
||||||
@@ -1708,8 +1788,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
<Input
|
<Input
|
||||||
id="filterInput"
|
id="filterInput"
|
||||||
ref={filterInput}
|
ref={filterInput}
|
||||||
|
{...restoreFocusTargetAttribute}
|
||||||
|
autocomplete="off"
|
||||||
type="text"
|
type="text"
|
||||||
list="filtersList"
|
list={`filtersList-${getUniqueId(_collection)}`}
|
||||||
className={`${filterContent.length === 0 ? "placeholderVisible" : ""}`}
|
className={`${filterContent.length === 0 ? "placeholderVisible" : ""}`}
|
||||||
style={{ width: "100%" }}
|
style={{ width: "100%" }}
|
||||||
title="Type a query predicate or choose one from the list."
|
title="Type a query predicate or choose one from the list."
|
||||||
@@ -1721,21 +1803,38 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
value={filterContent}
|
value={filterContent}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
onKeyDown={onFilterKeyDown}
|
onKeyDown={onFilterKeyDown}
|
||||||
onChange={(e) => setFilterContent(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setFilterContent(e.target.value);
|
||||||
|
setFilterMenuOpen(true);
|
||||||
|
}}
|
||||||
onBlur={() => setIsFilterFocused(false)}
|
onBlur={() => setIsFilterFocused(false)}
|
||||||
/>
|
/>
|
||||||
|
<Menu open={filterMenuOpen} onOpenChange={onFilterMenuOpenChange} positioning={{ positioningRef }}>
|
||||||
<datalist id="filtersList">
|
<MenuPopover>
|
||||||
{lastFilterContents.map((filter) => (
|
<MenuList>
|
||||||
|
{addStringsNoDuplicate(
|
||||||
|
lastFilterContents,
|
||||||
|
isPreferredApiMongoDB ? defaultMongoFilters : defaultSqlFilters,
|
||||||
|
).map((filter) => (
|
||||||
|
<MenuItem key={filter}>{filter}</MenuItem>
|
||||||
|
))}
|
||||||
|
</MenuList>
|
||||||
|
</MenuPopover>
|
||||||
|
</Menu>
|
||||||
|
{/* <datalist id={`filtersList-${getUniqueId(_collection)}`}>
|
||||||
|
{addStringsNoDuplicate(
|
||||||
|
lastFilterContents,
|
||||||
|
isPreferredApiMongoDB ? defaultMongoFilters : defaultSqlFilters,
|
||||||
|
).map((filter) => (
|
||||||
<option key={filter} value={filter} />
|
<option key={filter} value={filter} />
|
||||||
))}
|
))}
|
||||||
</datalist>
|
</datalist> */}
|
||||||
|
|
||||||
<span className="filterbuttonpad">
|
<span className="filterbuttonpad">
|
||||||
<Button
|
<Button
|
||||||
appearance="primary"
|
appearance="primary"
|
||||||
style={filterButtonStyle}
|
style={filterButtonStyle}
|
||||||
onClick={() => refreshDocumentsGrid(true)}
|
onClick={onApplyFilterClick}
|
||||||
disabled={!applyFilterButton.enabled}
|
disabled={!applyFilterButton.enabled}
|
||||||
aria-label="Apply filter"
|
aria-label="Apply filter"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@@ -1772,7 +1871,13 @@ 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%" }}>
|
||||||
<Split>
|
<Split
|
||||||
|
onDragEnd={(preSize: number) => {
|
||||||
|
tabStateData.leftPaneWidthPercent = Math.min(100, Math.max(0, Math.round(100 * preSize) / 100));
|
||||||
|
// saveDocumentsTabState(tabStateData); // Disable saving split position for now
|
||||||
|
setTabStateData({ ...tabStateData });
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{ minWidth: 120, width: "35%", overflow: "hidden", position: "relative" }}
|
style={{ minWidth: 120, width: "35%", overflow: "hidden", position: "relative" }}
|
||||||
ref={tableContainerRef}
|
ref={tableContainerRef}
|
||||||
@@ -1813,6 +1918,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}
|
||||||
/>
|
/>
|
||||||
{tableItems.length > 0 && (
|
{tableItems.length > 0 && (
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -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,17 +17,23 @@ 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,
|
||||||
|
WidthDefinition,
|
||||||
|
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
|
||||||
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
|
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
|
||||||
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;
|
||||||
@@ -45,6 +52,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> {
|
||||||
@@ -57,6 +65,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,
|
||||||
@@ -65,30 +78,32 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
|
|||||||
size,
|
size,
|
||||||
columnHeaders,
|
columnHeaders,
|
||||||
isSelectionDisabled,
|
isSelectionDisabled,
|
||||||
|
collection,
|
||||||
}: IDocumentsTableComponentProps) => {
|
}: IDocumentsTableComponentProps) => {
|
||||||
const initialSizingOptions: TableColumnSizingOptions = {
|
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(() => {
|
||||||
id: {
|
const columnIds = ["id"].concat(columnHeaders.partitionKeyHeaders);
|
||||||
idealWidth: 280,
|
const columnSizesMap: ColumnSizesMap = readSubComponentState("ColumnSizes", collection, {});
|
||||||
minWidth: 50,
|
const columnSizesPx: ColumnSizesMap = {};
|
||||||
},
|
columnIds.forEach((columnId) => {
|
||||||
};
|
columnSizesPx[columnId] = (columnSizesMap && columnSizesMap[columnId]) || defaultSize;
|
||||||
columnHeaders.partitionKeyHeaders.forEach((pkHeader) => {
|
});
|
||||||
initialSizingOptions[pkHeader] = {
|
return columnSizesPx;
|
||||||
idealWidth: 200,
|
|
||||||
minWidth: 50,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(initialSizingOptions);
|
|
||||||
|
|
||||||
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("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
|
||||||
|
|||||||
@@ -485,6 +485,7 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
|||||||
>
|
>
|
||||||
<Split
|
<Split
|
||||||
mode="horizontal"
|
mode="horizontal"
|
||||||
|
onDragEnd={[Function]}
|
||||||
prefixCls="w-split"
|
prefixCls="w-split"
|
||||||
visiable={true}
|
visiable={true}
|
||||||
>
|
>
|
||||||
@@ -526,6 +527,12 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<DocumentsTableComponent
|
<DocumentsTableComponent
|
||||||
|
collection={
|
||||||
|
Object {
|
||||||
|
"databaseId": "databaseId",
|
||||||
|
"id": [Function],
|
||||||
|
}
|
||||||
|
}
|
||||||
columnHeaders={
|
columnHeaders={
|
||||||
Object {
|
Object {
|
||||||
"idHeader": "id",
|
"idHeader": "id",
|
||||||
|
|||||||
@@ -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={
|
||||||
|
Object {
|
||||||
|
"databaseId": "db",
|
||||||
|
"id": [Function],
|
||||||
|
}
|
||||||
|
}
|
||||||
columnHeaders={
|
columnHeaders={
|
||||||
Object {
|
Object {
|
||||||
"idHeader": "id",
|
"idHeader": "id",
|
||||||
@@ -994,6 +1000,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={
|
||||||
|
Object {
|
||||||
|
"databaseId": "db",
|
||||||
|
"id": [Function],
|
||||||
|
}
|
||||||
|
}
|
||||||
columnHeaders={
|
columnHeaders={
|
||||||
Object {
|
Object {
|
||||||
"idHeader": "id",
|
"idHeader": "id",
|
||||||
|
|||||||
76
src/Shared/AppStatePersistenceUtility.ts
Normal file
76
src/Shared/AppStatePersistenceUtility.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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" | "DocumentsTab.columnSizes";
|
||||||
|
|
||||||
|
const SCHEMA_VERSION = 1;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO Add logic to clean up old state data based on timestamp
|
||||||
|
|
||||||
|
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
|
||||||
|
* @param path
|
||||||
|
*/
|
||||||
|
const createKeyFromPath = (path: StorePath): string => {
|
||||||
|
let key = `/${path.componentName}`; // ComponentName is always there
|
||||||
|
orderedPathSegments.forEach((segment) => {
|
||||||
|
const segmentValue = path[segment as keyof StorePath];
|
||||||
|
key += `/${segmentValue !== undefined ? segmentValue : ""}`;
|
||||||
|
});
|
||||||
|
return key;
|
||||||
|
};
|
||||||
@@ -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 => {
|
||||||
|
|||||||
Reference in New Issue
Block a user