Compare commits

...

7 Commits

Author SHA1 Message Date
Laurent Nguyen
88e47403d8 Replace datalist with fluentui menu 2024-07-31 16:17:49 +02:00
Laurent Nguyen
582d581edf Fix unit tests 2024-07-23 12:09:56 +02:00
Laurent Nguyen
f65e7ab1d6 Disable saving middle split position for now 2024-07-23 11:05:04 +02:00
Laurent Nguyen
bf1474e101 Make datalist for filter unique per database/container combination 2024-07-23 10:09:01 +02:00
Laurent Nguyen
4fda518a3f Replace read/save methods with more generic ones 2024-07-22 17:16:05 +02:00
Laurent Nguyen
9aa55aa4e5 Save filters 2024-07-22 16:21:26 +02:00
Laurent Nguyen
96b2ef728a Infrastructure to save app state 2024-07-22 12:17:56 +02:00
10 changed files with 349 additions and 34 deletions

View 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,
);
};

View File

@@ -13,6 +13,7 @@ import {
SAVE_BUTTON_ID,
UPDATE_BUTTON_ID,
UPLOAD_BUTTON_ID,
addStringsNoDuplicate,
buildQuery,
getDiscardExistingDocumentChangesButtonState,
getDiscardNewDocumentChangesButtonState,
@@ -339,7 +340,10 @@ describe("Documents tab (noSql API)", () => {
const createMockProps = (): IDocumentsTabComponentProps => ({
isPreferredApiMongoDB: false,
documentIds: [],
collection: undefined,
collection: {
id: ko.observable<string>("collectionId"),
databaseId: "databaseId",
} as ViewModels.CollectionBase,
partitionKey: { kind: "Hash", paths: ["/foo"], version: 2 },
onLoadStartKey: 0,
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"]);
});
});

View File

@@ -1,5 +1,17 @@
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 Split from "@uiw/react-split";
import { KeyCodes, QueryCopilotSampleContainerId, QueryCopilotSampleDatabaseId } from "Common/Constants";
@@ -21,6 +33,12 @@ import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
import Explorer from "Explorer/Explorer";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import {
DocumentsTabStateData,
readDocumentsTabState,
readSubComponentState,
saveSubComponentState,
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
import { getPlatformTheme } from "Explorer/Theme/ThemeUtil";
import { useSelectedNode } from "Explorer/useSelectedNode";
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
@@ -420,6 +438,24 @@ export const buildQuery = (
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 interface IDocumentsTabComponentProps {
isPreferredApiMongoDB: boolean;
@@ -434,6 +470,11 @@ export interface IDocumentsTabComponentProps {
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 const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabComponentProps> = ({
isPreferredApiMongoDB,
@@ -480,6 +521,9 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
ViewModels.DocumentExplorerState.noDocumentSelected,
);
// State
const [tabStateData, setTabStateData] = useState<DocumentsTabStateData>(() => readDocumentsTabState());
const isQueryCopilotSampleContainer =
_collection?.isSampleCollection &&
_collection?.databaseId === QueryCopilotSampleDatabaseId &&
@@ -488,6 +532,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// For Mongo only
const [continuationToken, setContinuationToken] = useState<string>(undefined);
// User's filter history
const [lastFilterContents, setLastFilterContents] = useState<string[]>(() =>
readSubComponentState("FilterHistory", _collection, []),
);
const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB);
useEffect(() => {
@@ -513,8 +562,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
}
}, [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 = {
enabled: true,
visible: true,
@@ -1303,6 +1350,26 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
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 = {
idHeader: isPreferredApiMongoDB ? "_id" : "id",
partitionKeyHeaders: (showPartitionKey(_collection, isPreferredApiMongoDB) && partitionKeyPropertyHeaders) || [],
@@ -1369,7 +1436,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
return partitionKey;
};
lastFilterContents = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
partitionKeyProperties = partitionKeyProperties?.map((partitionKeyProperty, i) => {
if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) {
partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, "");
@@ -1646,6 +1712,20 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
}
// ***************** 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(
(applyFilterButtonPressed: boolean): void => {
// clear documents grid
@@ -1708,8 +1788,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
<Input
id="filterInput"
ref={filterInput}
{...restoreFocusTargetAttribute}
autocomplete="off"
type="text"
list="filtersList"
list={`filtersList-${getUniqueId(_collection)}`}
className={`${filterContent.length === 0 ? "placeholderVisible" : ""}`}
style={{ width: "100%" }}
title="Type a query predicate or choose one from the list."
@@ -1721,21 +1803,38 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
value={filterContent}
autoFocus={true}
onKeyDown={onFilterKeyDown}
onChange={(e) => setFilterContent(e.target.value)}
onChange={(e) => {
setFilterContent(e.target.value);
setFilterMenuOpen(true);
}}
onBlur={() => setIsFilterFocused(false)}
/>
<datalist id="filtersList">
{lastFilterContents.map((filter) => (
<Menu open={filterMenuOpen} onOpenChange={onFilterMenuOpenChange} positioning={{ positioningRef }}>
<MenuPopover>
<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} />
))}
</datalist>
</datalist> */}
<span className="filterbuttonpad">
<Button
appearance="primary"
style={filterButtonStyle}
onClick={() => refreshDocumentsGrid(true)}
onClick={onApplyFilterClick}
disabled={!applyFilterButton.enabled}
aria-label="Apply filter"
tabIndex={0}
@@ -1772,7 +1871,13 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
)}
{/* <Split> doesn't like to be a flex child */}
<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
style={{ minWidth: 120, width: "35%", overflow: "hidden", position: "relative" }}
ref={tableContainerRef}
@@ -1813,6 +1918,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
(partitionKey.systemKey && !isPreferredApiMongoDB) ||
(configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly)
}
collection={_collection}
/>
{tableItems.length > 0 && (
<a

View File

@@ -1,6 +1,7 @@
import { TableRowId } from "@fluentui/react-components";
import { mount } from "enzyme";
import React from "react";
import * as ViewModels from "../../../Contracts/ViewModels";
import { DocumentsTableComponent, IDocumentsTableComponentProps } from "./DocumentsTableComponent";
const PARTITION_KEY_HEADER = "partitionKey";
@@ -25,6 +26,10 @@ describe("DocumentsTableComponent", () => {
partitionKeyHeaders: [PARTITION_KEY_HEADER],
},
isSelectionDisabled: false,
collection: {
databaseId: "db",
id: ((): string => "coll") as ko.Observable<string>,
} as ViewModels.CollectionBase,
});
it("should render documents and partition keys in header", () => {

View File

@@ -1,4 +1,5 @@
import {
createTableColumn,
Menu,
MenuItem,
MenuList,
@@ -16,17 +17,23 @@ import {
TableRow,
TableRowId,
TableSelectionCell,
createTableColumn,
useArrowNavigationGroup,
useTableColumnSizing_unstable,
useTableFeatures,
useTableSelection,
} from "@fluentui/react-components";
import { NormalizedEventKey } from "Common/Constants";
import {
ColumnSizesMap,
readSubComponentState,
saveSubComponentState,
WidthDefinition,
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils";
import React, { useCallback, useMemo } from "react";
import { FixedSizeList as List, ListChildComponentProps } from "react-window";
import * as ViewModels from "../../../Contracts/ViewModels";
export type DocumentsTableComponentItem = {
id: string;
@@ -45,6 +52,7 @@ export interface IDocumentsTableComponentProps {
columnHeaders: ColumnHeaders;
style?: React.CSSProperties;
isSelectionDisabled?: boolean;
collection: ViewModels.CollectionBase;
}
interface TableRowData extends RowStateBase<DocumentsTableComponentItem> {
@@ -57,6 +65,11 @@ interface ReactWindowRenderFnProps extends ListChildComponentProps {
data: TableRowData[];
}
const defaultSize: WidthDefinition = {
idealWidth: 200,
minWidth: 50,
};
export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({
items,
onSelectedRowsChange,
@@ -65,30 +78,32 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
size,
columnHeaders,
isSelectionDisabled,
collection,
}: IDocumentsTableComponentProps) => {
const initialSizingOptions: TableColumnSizingOptions = {
id: {
idealWidth: 280,
minWidth: 50,
},
};
columnHeaders.partitionKeyHeaders.forEach((pkHeader) => {
initialSizingOptions[pkHeader] = {
idealWidth: 200,
minWidth: 50,
};
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(() => {
const columnIds = ["id"].concat(columnHeaders.partitionKeyHeaders);
const columnSizesMap: ColumnSizesMap = readSubComponentState("ColumnSizes", collection, {});
const columnSizesPx: ColumnSizesMap = {};
columnIds.forEach((columnId) => {
columnSizesPx[columnId] = (columnSizesMap && columnSizesMap[columnId]) || defaultSize;
});
return columnSizesPx;
});
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(initialSizingOptions);
const onColumnResize = React.useCallback((_, { columnId, width }) => {
setColumnSizingOptions((state) => ({
...state,
[columnId]: {
...state[columnId],
idealWidth: width,
},
}));
setColumnSizingOptions((state) => {
const newSizingOptions = {
...state,
[columnId]: {
...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

View File

@@ -485,6 +485,7 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
>
<Split
mode="horizontal"
onDragEnd={[Function]}
prefixCls="w-split"
visiable={true}
>
@@ -526,6 +527,12 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
}
>
<DocumentsTableComponent
collection={
Object {
"databaseId": "databaseId",
"id": [Function],
}
}
columnHeaders={
Object {
"idHeader": "id",

View File

@@ -2,6 +2,12 @@
exports[`DocumentsTableComponent should not render selection column when isSelectionDisabled is true 1`] = `
<DocumentsTableComponent
collection={
Object {
"databaseId": "db",
"id": [Function],
}
}
columnHeaders={
Object {
"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`] = `
<DocumentsTableComponent
collection={
Object {
"databaseId": "db",
"id": [Function],
}
}
columnHeaders={
Object {
"idHeader": "id",

View 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;
};

View File

@@ -20,3 +20,14 @@ export const setEntryNumber = (key: StorageKey, value: number): void =>
export const setEntryBoolean = (key: StorageKey, value: boolean): void =>
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;
};

View File

@@ -30,6 +30,7 @@ export enum StorageKey {
VisitedAccounts,
PriorityLevel,
DefaultQueryResultsView,
AppState,
}
export const hasRUThresholdBeenConfigured = (): boolean => {