Compare commits

...

42 Commits

Author SHA1 Message Date
Laurent Nguyen
04c01256a6 Cleanup checkbox styles 2024-09-05 11:40:14 +02:00
Laurent Nguyen
1795b8e2e9 Merge branch 'master' into users/languy/save-documentstab-prefs 2024-09-05 09:41:58 +02:00
Laurent Nguyen
e23ba02561 Move column selection and sorting behind feature flag enableDocumentsTableColumnSelection 2024-09-02 12:31:39 +02:00
Laurent Nguyen
85352b74a3 Update unit test snapshot 2024-08-23 18:07:59 +02:00
Laurent Nguyen
26645f8360 Label to indicate which field is a partition key in Column Selection Pane 2024-08-23 17:59:16 +02:00
Laurent Nguyen
777b695051 Add background color to "..." button for column selection 2024-08-23 17:47:02 +02:00
Laurent Nguyen
1f300e32fe Fix table width 2024-08-23 17:32:00 +02:00
Laurent Nguyen
e81408560e Remove unused function 2024-08-23 17:26:22 +02:00
Laurent Nguyen
ed1e2990d0 Update test snapshots 2024-08-23 17:21:40 +02:00
Laurent Nguyen
5e92a0c5d7 Disable column selection for Mongo. Remove extra refresh button 2024-08-23 17:16:43 +02:00
Laurent Nguyen
26b6de4c53 Merge branch 'master' into users/languy/save-documentstab-prefs 2024-08-23 09:57:52 +02:00
Laurent Nguyen
f308cabeaa Revert "Merge branch 'master' into users/languy/save-documentstab-prefs"
This reverts commit e5a82fd356.
2024-08-23 09:53:45 +02:00
Laurent Nguyen
e5a82fd356 Merge branch 'master' into users/languy/save-documentstab-prefs 2024-08-23 09:47:19 +02:00
Laurent Nguyen
4778183e50 Save columns definition (schema) along with selected columns. 2024-08-23 09:23:38 +02:00
Laurent Nguyen
b1d9570a95 Persist column sorting 2024-08-23 08:21:40 +02:00
Laurent Nguyen
2397283649 Persist column selection 2024-08-22 16:19:23 +02:00
Laurent Nguyen
905aa26f27 Fix unit test 2024-08-22 14:30:35 +02:00
Laurent Nguyen
a2556dad06 Fix unit tests 2024-08-22 14:24:44 +02:00
Laurent Nguyen
c9398e303b Merge branch 'master' into users/languy/save-documentstab-prefs 2024-08-22 11:48:38 +02:00
Laurent Nguyen
9d4a9c0601 Add reset button to column selection and fix naming of openUploadItemsPanePane() 2024-08-21 16:39:21 +02:00
Laurent Nguyen
1e10273510 Fix format, update snapshots 2024-08-07 09:36:48 +02:00
Laurent Nguyen
c141e2612b Merge branch 'master' into users/languy/save-documentstab-prefs 2024-08-07 09:27:13 +02:00
Laurent Nguyen
7a179ff34a Fix format 2024-08-06 18:28:52 +02:00
Laurent Nguyen
4e71e340e3 Implement column sorting 2024-07-11 14:33:55 +02:00
Laurent Nguyen
9efbe7d056 Don't allow unselecting last column 2024-07-11 10:04:43 +02:00
Laurent Nguyen
ea2ab19518 Fix heuristic for size update 2024-07-11 10:04:21 +02:00
Laurent Nguyen
5d59c47979 Fix table size issue with some heuristics 2024-07-10 19:14:13 +02:00
Laurent Nguyen
fa460bfba2 Rework column selection UI 2024-07-10 17:20:24 +02:00
Laurent Nguyen
f1dcf1c548 Update choices of column when creating new or updating document 2024-06-25 10:15:41 +02:00
Laurent Nguyen
88f38d6522 Move table values under its own property 2024-06-24 13:38:32 +02:00
Laurent Nguyen
658e2ffe85 Do not allow deselecting all columns 2024-06-21 13:12:50 +02:00
Laurent Nguyen
bea3aa8b55 Accumulate properties rather than replace for column definitions 2024-06-21 12:05:50 +02:00
Laurent Nguyen
ce0cfed128 Only allow data fields that can be rendered (string and numbers) in column selection 2024-06-21 11:42:02 +02:00
Laurent Nguyen
c0a79c1e67 Search uses string includes instead of startsWith 2024-06-20 16:40:04 +02:00
Laurent Nguyen
9945304e18 Switch icons and search compare with lowercase. 2024-06-20 16:36:01 +02:00
Laurent Nguyen
0ce9acdfdf Implement new menu for column selection with search. 2024-06-20 16:23:36 +02:00
Laurent Nguyen
b096fa9bf8 Add column selection from right-click 2024-06-19 13:01:23 +02:00
Laurent Nguyen
55df5cb121 Merge branch 'master' into users/languy/save-documentstab-prefs 2024-06-13 09:14:11 +02:00
Laurent Nguyen
e36853c100 Save column width 2024-06-12 12:26:28 +02:00
Laurent Nguyen
996f785aac Merge branch 'master' into users/languy/save-documentstab-prefs 2024-06-11 17:02:04 +02:00
Laurent Nguyen
6c67f3b2e5 Make table columns generic (no more id and partition keys) 2024-06-11 16:57:17 +02:00
Laurent Nguyen
1ee79881ef Initial implementation of saving split value to local storage 2024-06-10 14:25:58 +02:00
14 changed files with 742 additions and 1332 deletions

56
package-lock.json generated
View File

@@ -2527,13 +2527,13 @@
} }
}, },
"node_modules/@babel/preset-env/node_modules/babel-plugin-polyfill-corejs3": { "node_modules/@babel/preset-env/node_modules/babel-plugin-polyfill-corejs3": {
"version": "0.10.4", "version": "0.10.6",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.4.tgz", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz",
"integrity": "sha512-25J6I8NGfa5YkCDogHRID3fVCadIR8/pGl1/spvCkzb6lVn6SR3ojpx9nOn9iEBcUsjY24AmdKm5khcfKdylcg==", "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@babel/helper-define-polyfill-provider": "^0.6.1", "@babel/helper-define-polyfill-provider": "^0.6.2",
"core-js-compat": "^3.36.1" "core-js-compat": "^3.38.0"
}, },
"peerDependencies": { "peerDependencies": {
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
@@ -2932,10 +2932,10 @@
} }
}, },
"node_modules/@floating-ui/core": { "node_modules/@floating-ui/core": {
"version": "1.6.3", "version": "1.6.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/utils": "^0.2.3" "@floating-ui/utils": "^0.2.0"
} }
}, },
"node_modules/@floating-ui/devtools": { "node_modules/@floating-ui/devtools": {
@@ -2945,15 +2945,15 @@
} }
}, },
"node_modules/@floating-ui/dom": { "node_modules/@floating-ui/dom": {
"version": "1.6.6", "version": "1.6.5",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.0.0", "@floating-ui/core": "^1.0.0",
"@floating-ui/utils": "^0.2.3" "@floating-ui/utils": "^0.2.0"
} }
}, },
"node_modules/@floating-ui/utils": { "node_modules/@floating-ui/utils": {
"version": "0.2.3", "version": "0.2.2",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@fluentui/date-time-utilities": { "node_modules/@fluentui/date-time-utilities": {
@@ -3501,7 +3501,7 @@
"resolved": "https://registry.npmjs.org/@fluentui/react-hooks/-/react-hooks-8.8.10.tgz", "resolved": "https://registry.npmjs.org/@fluentui/react-hooks/-/react-hooks-8.8.10.tgz",
"integrity": "sha512-Xvnn6uKMsinMg/zo79KBNCDABnl0gpmArQYNQya9FCNRzvmHUCDvuQCqv4IKslvPvuC0Ya8mR2NORm2w0JoZiw==", "integrity": "sha512-Xvnn6uKMsinMg/zo79KBNCDABnl0gpmArQYNQya9FCNRzvmHUCDvuQCqv4IKslvPvuC0Ya8mR2NORm2w0JoZiw==",
"dependencies": { "dependencies": {
"@fluentui/react-window-provider": "^2.2.27", "@fluentui/react-window-provider": "^2.2.28",
"@fluentui/set-version": "^8.2.23", "@fluentui/set-version": "^8.2.23",
"@fluentui/utilities": "^8.15.13", "@fluentui/utilities": "^8.15.13",
"tslib": "^2.1.0" "tslib": "^2.1.0"
@@ -4426,9 +4426,9 @@
} }
}, },
"node_modules/@fluentui/react-window-provider": { "node_modules/@fluentui/react-window-provider": {
"version": "2.2.27", "version": "2.2.28",
"resolved": "https://registry.npmjs.org/@fluentui/react-window-provider/-/react-window-provider-2.2.27.tgz", "resolved": "https://registry.npmjs.org/@fluentui/react-window-provider/-/react-window-provider-2.2.28.tgz",
"integrity": "sha512-Dg0G9bizjryV0Q/r0CPtCVTPa2II/EsT9E6JT3jPSALjQADDLlW4/+ZXbcEC7geZ/40+KpZDmhplvk/AJSFBKg==", "integrity": "sha512-YdZ74HTaoDwlvLDzoBST80/17ExIl93tLJpTxnqK5jlJOAUVQ+mxLPF2HQEJq+SZr5IMXHsQ56w/KaZVRn72YA==",
"dependencies": { "dependencies": {
"@fluentui/set-version": "^8.2.23", "@fluentui/set-version": "^8.2.23",
"tslib": "^2.1.0" "tslib": "^2.1.0"
@@ -4512,7 +4512,7 @@
"dependencies": { "dependencies": {
"@fluentui/dom-utilities": "^2.3.7", "@fluentui/dom-utilities": "^2.3.7",
"@fluentui/merge-styles": "^8.6.12", "@fluentui/merge-styles": "^8.6.12",
"@fluentui/react-window-provider": "^2.2.27", "@fluentui/react-window-provider": "^2.2.28",
"@fluentui/set-version": "^8.2.23", "@fluentui/set-version": "^8.2.23",
"tslib": "^2.1.0" "tslib": "^2.1.0"
}, },
@@ -14966,9 +14966,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.23.2", "version": "4.23.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.2.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz",
"integrity": "sha512-qkqSyistMYdxAcw+CzbZwlBy8AGmS/eEWs+sEV5TnLRGDOL+C5M2EnH6tlZyg0YoAxGJAFKh61En9BR941GnHA==", "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -14984,9 +14984,9 @@
} }
], ],
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001640", "caniuse-lite": "^1.0.30001646",
"electron-to-chromium": "^1.4.820", "electron-to-chromium": "^1.5.4",
"node-releases": "^2.0.14", "node-releases": "^2.0.18",
"update-browserslist-db": "^1.1.0" "update-browserslist-db": "^1.1.0"
}, },
"bin": { "bin": {
@@ -15142,9 +15142,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001645", "version": "1.0.30001651",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001645.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz",
"integrity": "sha512-GFtY2+qt91kzyMk6j48dJcwJVq5uTkk71XxE3RtScx7XWRLsO7bU44LOFkOZYR8w9YMS0UhPSYpN/6rAMImmLw==", "integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -16063,12 +16063,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/core-js-compat": { "node_modules/core-js-compat": {
"version": "3.37.1", "version": "3.38.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.1.tgz", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.38.0.tgz",
"integrity": "sha512-9TNiImhKvQqSUkOvk/mMRZzOANTiEVC7WaBNhHcKM7x+/5E1l5NvsysR19zuDQScE8k+kfQXWRN3AtS/eOSHpg==", "integrity": "sha512-75LAicdLa4OJVwFxFbQR3NdnZjNgX6ILpVcVzcC4T2smerB5lELMrJQQQoWV6TiuC/vlaFqgU2tKQx9w5s0e0A==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"browserslist": "^4.23.0" "browserslist": "^4.23.3"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",

View File

@@ -1119,7 +1119,7 @@ export default class Explorer {
} }
} }
public openUploadItemsPanePane(): void { public openUploadItemsPane(): void {
useSidePanel.getState().openSidePanel("Upload " + getUploadName(), <UploadItemsPane />); useSidePanel.getState().openSidePanel("Upload " + getUploadName(), <UploadItemsPane />);
} }
public openExecuteSprocParamsPanel(storedProcedure: StoredProcedure): void { public openExecuteSprocParamsPanel(storedProcedure: StoredProcedure): void {

View File

@@ -0,0 +1,148 @@
import {
Button,
Checkbox,
CheckboxOnChangeData,
InputOnChangeData,
makeStyles,
SearchBox,
SearchBoxChangeEvent,
Text,
} from "@fluentui/react-components";
import { configContext } from "ConfigContext";
import { ColumnDefinition } from "Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent";
import { CosmosFluentProvider, getPlatformTheme } from "Explorer/Theme/ThemeUtil";
import React from "react";
import { useSidePanel } from "../../../hooks/useSidePanel";
const useColumnSelectionStyles = makeStyles({
paneContainer: {
height: "100%",
display: "flex",
},
searchBox: {
width: "100%",
},
checkboxContainer: {
display: "flex",
flexDirection: "column",
flex: 1,
},
checkboxLabel: {
padding: "4px 8px",
marginBottom: "0px",
},
});
export interface TableColumnSelectionPaneProps {
columnDefinitions: ColumnDefinition[];
selectedColumnIds: string[];
onSelectionChange: (newSelectedColumnIds: string[]) => void;
defaultSelection: string[];
}
export const TableColumnSelectionPane: React.FC<TableColumnSelectionPaneProps> = ({
columnDefinitions,
selectedColumnIds,
onSelectionChange,
defaultSelection,
}: TableColumnSelectionPaneProps): JSX.Element => {
const closeSidePanel = useSidePanel((state) => state.closeSidePanel);
const originalSelectedColumnIds = React.useMemo(() => selectedColumnIds, []);
const [columnSearchText, setColumnSearchText] = React.useState<string>("");
const [newSelectedColumnIds, setNewSelectedColumnIds] = React.useState<string[]>(originalSelectedColumnIds);
const styles = useColumnSelectionStyles();
const selectedColumnIdsSet = new Set(newSelectedColumnIds);
const onCheckedValueChange = (id: string, checkedData?: CheckboxOnChangeData): void => {
const checked = checkedData?.checked;
if (checked === "mixed" || checked === undefined) {
return;
}
if (checked) {
selectedColumnIdsSet.add(id);
} else {
if (selectedColumnIdsSet.size === 1 && selectedColumnIdsSet.has(id)) {
// Don't allow unchecking the last column
return;
}
selectedColumnIdsSet.delete(id);
}
setNewSelectedColumnIds([...selectedColumnIdsSet]);
};
const onSave = (): void => {
onSelectionChange(newSelectedColumnIds);
closeSidePanel();
};
const onSearchChange: (event: SearchBoxChangeEvent, data: InputOnChangeData) => void = (_, data) =>
// eslint-disable-next-line react/prop-types
setColumnSearchText(data.value);
const theme = getPlatformTheme(configContext.platform);
// Filter and move partition keys to the top
const columnDefinitionList = columnDefinitions
.filter((def) => !columnSearchText || def.label.toLowerCase().includes(columnSearchText.toLowerCase()))
.sort((a, b) => {
const ID = "id";
// "id" always at the top, then partition keys, then everything else sorted
if (a.id === ID) {
return b.id === ID ? 0 : -1;
} else if (b.id === ID) {
return a.id === ID ? 0 : 1;
} else if (a.isPartitionKey && !b.isPartitionKey) {
return -1;
} else if (b.isPartitionKey && !a.isPartitionKey) {
return 1;
} else {
return a.label.localeCompare(b.label);
}
});
return (
<div className={styles.paneContainer}>
<CosmosFluentProvider>
<div className="panelFormWrapper">
<div className="panelMainContent" style={{ display: "flex", flexDirection: "column" }}>
<Text>Select which columns to display in your view of items in your container.</Text>
<div /* Wrap <SearchBox> to avoid margin-bottom set by panelMainContent css */>
<SearchBox
className={styles.searchBox}
value={columnSearchText}
onChange={onSearchChange}
placeholder="Search fields"
/>
</div>
<div className={styles.checkboxContainer}>
{columnDefinitionList.map((columnDefinition) => (
<Checkbox
style={{ marginBottom: 0 }}
key={columnDefinition.id}
label={{
className: styles.checkboxLabel,
children: `${columnDefinition.label}${columnDefinition.isPartitionKey ? " (partition key)" : ""}`,
}}
checked={selectedColumnIdsSet.has(columnDefinition.id)}
onChange={(_, data) => onCheckedValueChange(columnDefinition.id, data)}
/>
))}
</div>
<Button appearance="secondary" size="small" onClick={() => setNewSelectedColumnIds(defaultSelection)}>
Reset
</Button>
</div>
<div className="panelFooter" style={{ display: "flex", gap: theme.spacingHorizontalS }}>
<Button appearance="primary" onClick={onSave}>
Save
</Button>
<Button appearance="secondary" onClick={closeSidePanel}>
Cancel
</Button>
</div>
</div>
</CosmosFluentProvider>
</div>
);
};

View File

@@ -1,5 +1,6 @@
// Definitions of State data // Definitions of State data
import { ColumnDefinition } from "Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent";
import { deleteState, loadState, saveState, saveStateDebounced } from "Shared/AppStatePersistenceUtility"; import { deleteState, loadState, saveState, saveStateDebounced } from "Shared/AppStatePersistenceUtility";
import { userContext } from "UserContext"; import { userContext } from "UserContext";
import * as ViewModels from "../../../Contracts/ViewModels"; import * as ViewModels from "../../../Contracts/ViewModels";
@@ -11,11 +12,16 @@ export enum SubComponentName {
ColumnSizes = "ColumnSizes", ColumnSizes = "ColumnSizes",
FilterHistory = "FilterHistory", FilterHistory = "FilterHistory",
MainTabDivider = "MainTabDivider", MainTabDivider = "MainTabDivider",
ColumnsSelection = "ColumnsSelection",
ColumnSort = "ColumnSort",
} }
export type ColumnSizesMap = { [columnId: string]: WidthDefinition }; export type ColumnSizesMap = { [columnId: string]: WidthDefinition };
export type FilterHistory = string[];
export type WidthDefinition = { widthPx: number }; export type WidthDefinition = { widthPx: number };
export type TabDivider = { leftPaneWidthPercent: number }; export type TabDivider = { leftPaneWidthPercent: number };
export type ColumnsSelection = { selectedColumnIds: string[]; columnDefinitions: ColumnDefinition[] };
export type ColumnSort = { columnId: string; direction: "ascending" | "descending" };
/** /**
* *

View File

@@ -92,7 +92,13 @@ async function waitForComponentToPaint<P = unknown>(wrapper: ReactWrapper<P> | S
describe("Documents tab (noSql API)", () => { describe("Documents tab (noSql API)", () => {
describe("buildQuery", () => { describe("buildQuery", () => {
it("should generate the right select query for SQL API", () => { it("should generate the right select query for SQL API", () => {
expect(buildQuery(false, "")).toContain("select"); expect(
buildQuery(false, "", ["pk"], {
paths: ["pk"],
kind: "Hash",
version: 2,
}),
).toContain("select");
}); });
}); });

View File

@@ -1,10 +1,9 @@
import { Item, ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos"; import { Item, ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos";
import { Button, Input, TableRowId, makeStyles, shorthands } from "@fluentui/react-components"; import { Button, Input, TableRowId, makeStyles, shorthands } from "@fluentui/react-components";
import { ArrowClockwise16Filled, Dismiss16Filled } from "@fluentui/react-icons"; import { Dismiss16Filled } from "@fluentui/react-icons";
import { KeyCodes, QueryCopilotSampleContainerId, QueryCopilotSampleDatabaseId } from "Common/Constants"; import { KeyCodes, QueryCopilotSampleContainerId, QueryCopilotSampleDatabaseId } from "Common/Constants";
import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils"; import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
import MongoUtility from "Common/MongoUtility"; import MongoUtility from "Common/MongoUtility";
import { StyleConstants } from "Common/StyleConstants";
import { createDocument } from "Common/dataAccess/createDocument"; import { createDocument } from "Common/dataAccess/createDocument";
import { import {
deleteDocument as deleteNoSqlDocument, deleteDocument as deleteNoSqlDocument,
@@ -21,11 +20,14 @@ 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 { import {
ColumnsSelection,
FilterHistory,
SubComponentName, SubComponentName,
TabDivider, TabDivider,
readSubComponentState, readSubComponentState,
saveSubComponentState, saveSubComponentState,
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil"; } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
import { usePrevious } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
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,11 +53,11 @@ import * as ViewModels from "../../../Contracts/ViewModels";
import { CollectionBase } from "../../../Contracts/ViewModels"; import { CollectionBase } from "../../../Contracts/ViewModels";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
import * as QueryUtils from "../../../Utils/QueryUtils"; import * as QueryUtils from "../../../Utils/QueryUtils";
import { extractPartitionKeyValues } from "../../../Utils/QueryUtils"; import { defaultQueryFields, extractPartitionKeyValues } from "../../../Utils/QueryUtils";
import DocumentId from "../../Tree/DocumentId"; import DocumentId from "../../Tree/DocumentId";
import ObjectId from "../../Tree/ObjectId"; import ObjectId from "../../Tree/ObjectId";
import TabsBase from "../TabsBase"; import TabsBase from "../TabsBase";
import { DocumentsTableComponent, DocumentsTableComponentItem } from "./DocumentsTableComponent"; import { ColumnDefinition, 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 MAX_FILTER_HISTORY_COUNT = 100; // Datalist will become scrollable, so we can afford to keep more items than fit on the screen
@@ -101,17 +103,6 @@ export const useDocumentsTabStyles = makeStyles({
...shorthands.outline("1px", "dotted"), ...shorthands.outline("1px", "dotted"),
}, },
}, },
floatingControlsContainer: {
position: "relative",
},
floatingControls: {
position: "absolute",
top: "6px",
right: 0,
float: "right",
backgroundColor: "white",
zIndex: 1,
},
}); });
export class DocumentsTabV2 extends TabsBase { export class DocumentsTabV2 extends TabsBase {
@@ -281,7 +272,7 @@ const createUploadButton = (container: Explorer): CommandButtonComponentProps =>
iconAlt: label, iconAlt: label,
onCommandClick: () => { onCommandClick: () => {
const selectedCollection: ViewModels.Collection = useSelectedNode.getState().findSelectedCollection(); const selectedCollection: ViewModels.Collection = useSelectedNode.getState().findSelectedCollection();
selectedCollection && container.openUploadItemsPanePane(); selectedCollection && container.openUploadItemsPane();
}, },
commandButtonLabel: label, commandButtonLabel: label,
ariaLabel: label, ariaLabel: label,
@@ -469,17 +460,33 @@ export const showPartitionKey = (collection: ViewModels.CollectionBase, isPrefer
}; };
// Export to expose to unit tests // Export to expose to unit tests
/**
* Build default query
* @param isMongo true if mongo api
* @param filter
* @param partitionKeyProperties optional for mongo
* @param partitionKey optional for mongo
* @param additionalField
* @returns
*/
export const buildQuery = ( export const buildQuery = (
isMongo: boolean, isMongo: boolean,
filter: string, filter: string,
partitionKeyProperties?: string[], partitionKeyProperties?: string[],
partitionKey?: DataModels.PartitionKey, partitionKey?: DataModels.PartitionKey,
additionalField?: string[],
): string => { ): string => {
if (isMongo) { if (isMongo) {
return filter || "{}"; return filter || "{}";
} }
return QueryUtils.buildDocumentsQuery(filter, partitionKeyProperties, partitionKey); // Filter out fields starting with "/" (partition keys)
return QueryUtils.buildDocumentsQuery(
filter,
partitionKeyProperties,
partitionKey,
additionalField?.filter((f) => !f.startsWith("/")) || [],
);
}; };
/** /**
@@ -522,6 +529,12 @@ const getDefaultSqlFilters = (partitionKeys: string[]) =>
); );
const defaultMongoFilters = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"]; const defaultMongoFilters = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
// Extend DocumentId to include fields displayed in the table
type ExtendedDocumentId = DocumentId & { tableFields?: DocumentsTableComponentItem };
// This is based on some heuristics
const calculateOffset = (columnNumber: number): number => columnNumber * 16 - 29;
// 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,
@@ -540,7 +553,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
const [isFilterFocused, setIsFilterFocused] = useState<boolean>(false); const [isFilterFocused, setIsFilterFocused] = useState<boolean>(false);
const [appliedFilter, setAppliedFilter] = useState<string>(""); const [appliedFilter, setAppliedFilter] = useState<string>("");
const [filterContent, setFilterContent] = useState<string>(""); const [filterContent, setFilterContent] = useState<string>("");
const [documentIds, setDocumentIds] = useState<DocumentId[]>([]); const [documentIds, setDocumentIds] = useState<ExtendedDocumentId[]>([]);
const [isExecuting, setIsExecuting] = useState<boolean>(false); const [isExecuting, setIsExecuting] = useState<boolean>(false);
const filterInput = useRef<HTMLInputElement>(null); const filterInput = useRef<HTMLInputElement>(null);
const styles = useDocumentsTabStyles(); const styles = useDocumentsTabStyles();
@@ -571,7 +584,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// State // State
const [tabStateData, setTabStateData] = useState<TabDivider>(() => const [tabStateData, setTabStateData] = useState<TabDivider>(() =>
readSubComponentState(SubComponentName.MainTabDivider, _collection, { readSubComponentState<TabDivider>(SubComponentName.MainTabDivider, _collection, {
leftPaneWidthPercent: 35, leftPaneWidthPercent: 35,
}), }),
); );
@@ -585,8 +598,8 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
const [continuationToken, setContinuationToken] = useState<string>(undefined); const [continuationToken, setContinuationToken] = useState<string>(undefined);
// User's filter history // User's filter history
const [lastFilterContents, setLastFilterContents] = useState<string[]>(() => const [lastFilterContents, setLastFilterContents] = useState<FilterHistory>(() =>
readSubComponentState(SubComponentName.FilterHistory, _collection, []), readSubComponentState<FilterHistory>(SubComponentName.FilterHistory, _collection, [] as FilterHistory),
); );
const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB); const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB);
@@ -635,10 +648,37 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
[partitionKeyPropertyHeaders], [partitionKeyPropertyHeaders],
); );
const getInitialColumnSelection = () => {
const defaultColumnsIds = ["id"];
if (showPartitionKey(_collection, isPreferredApiMongoDB)) {
defaultColumnsIds.push(...partitionKeyPropertyHeaders);
}
return defaultColumnsIds;
};
const [selectedColumnIds, setSelectedColumnIds] = useState<string[]>(() => {
const persistedColumnsSelection = readSubComponentState<ColumnsSelection>(
SubComponentName.ColumnsSelection,
_collection,
undefined,
);
if (!persistedColumnsSelection) {
return getInitialColumnSelection();
}
return persistedColumnsSelection.selectedColumnIds;
});
// new DocumentId() requires a DocumentTab which we mock with only the required properties // new DocumentId() requires a DocumentTab which we mock with only the required properties
const newDocumentId = useCallback( const newDocumentId = useCallback(
(rawDocument: DataModels.DocumentId, partitionKeyProperties: string[], partitionKeyValue: string[]) => (
new DocumentId( rawDocument: DataModels.DocumentId,
partitionKeyProperties: string[],
partitionKeyValue: string[],
): ExtendedDocumentId => {
const extendedDocumentId = new DocumentId(
{ {
partitionKey, partitionKey,
partitionKeyProperties, partitionKeyProperties,
@@ -648,7 +688,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
}, },
rawDocument, rawDocument,
partitionKeyValue, partitionKeyValue,
), ) as ExtendedDocumentId;
extendedDocumentId.tableFields = { ...rawDocument };
return extendedDocumentId;
},
[partitionKey], [partitionKey],
); );
@@ -810,6 +853,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
setDocumentIds(ids); setDocumentIds(ids);
setEditorState(ViewModels.DocumentExplorerState.existingDocumentNoEdits); setEditorState(ViewModels.DocumentExplorerState.existingDocumentNoEdits);
// Update column choices
setColumnDefinitionsFromDocument(savedDocument);
TelemetryProcessor.traceSuccess( TelemetryProcessor.traceSuccess(
Action.CreateDocument, Action.CreateDocument,
{ {
@@ -892,6 +939,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
}, },
startKey, startKey,
); );
// Update column choices
selectedDocumentId.tableFields = { ...documentContent };
setColumnDefinitionsFromDocument(documentContent);
}, },
(error) => { (error) => {
onExecutionErrorChange(true); onExecutionErrorChange(true);
@@ -1093,7 +1144,13 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
const _queryAbortController = new AbortController(); const _queryAbortController = new AbortController();
setQueryAbortController(_queryAbortController); setQueryAbortController(_queryAbortController);
const filter: string = filterContent.trim(); const filter: string = filterContent.trim();
const query: string = buildQuery(isPreferredApiMongoDB, filter, partitionKeyProperties, partitionKey); const query: string = buildQuery(
isPreferredApiMongoDB,
filter,
partitionKeyProperties,
partitionKey,
selectedColumnIds,
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const options: any = {}; const options: any = {};
// TODO: Property 'enableCrossPartitionQuery' does not exist on type 'FeedOptions'. // TODO: Property 'enableCrossPartitionQuery' does not exist on type 'FeedOptions'.
@@ -1116,6 +1173,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
resourceTokenPartitionKey, resourceTokenPartitionKey,
isQueryCopilotSampleContainer, isQueryCopilotSampleContainer,
_collection, _collection,
selectedColumnIds,
]); ]);
const onHideFilterClick = (): void => { const onHideFilterClick = (): void => {
@@ -1261,16 +1319,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
documentsIterator, // loadNextPage: disabled as it will trigger a circular dependency and infinite loop documentsIterator, // loadNextPage: disabled as it will trigger a circular dependency and infinite loop
]); ]);
const onRefreshKeyInput: KeyboardEventHandler<HTMLButtonElement> = (event) => {
if (event.key === " " || event.key === "Enter") {
const focusElement = event.target as HTMLElement;
refreshDocumentsGrid(false);
focusElement && focusElement.focus();
event.stopPropagation();
event.preventDefault();
}
};
const onLoadMoreKeyInput: KeyboardEventHandler<HTMLAnchorElement> = (event) => { const onLoadMoreKeyInput: KeyboardEventHandler<HTMLAnchorElement> = (event) => {
if (event.key === " " || event.key === "Enter") { if (event.key === " " || event.key === "Enter") {
const focusElement = event.target as HTMLElement; const focusElement = event.target as HTMLElement;
@@ -1302,9 +1350,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// Table config here // Table config here
const tableItems: DocumentsTableComponentItem[] = documentIds.map((documentId) => { const tableItems: DocumentsTableComponentItem[] = documentIds.map((documentId) => {
const item: Record<string, string> & { id: string } = { const item: DocumentsTableComponentItem = documentId.tableFields || { id: documentId.id() };
id: documentId.id(),
};
if (partitionKeyPropertyHeaders && documentId.stringPartitionKeyValues) { if (partitionKeyPropertyHeaders && documentId.stringPartitionKeyValues) {
for (let i = 0; i < partitionKeyPropertyHeaders.length; i++) { for (let i = 0; i < partitionKeyPropertyHeaders.length; i++) {
@@ -1315,6 +1361,44 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
return item; return item;
}); });
const extractColumnDefinitionsFromDocument = (document: unknown): ColumnDefinition[] => {
let columnDefinitions: ColumnDefinition[] = Object.keys(document)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.filter((key) => typeof (document as any)[key] === "string" || typeof (document as any)[key] === "number") // Only allow safe types for displayable React children
.map((key) =>
key === "id"
? { id: key, label: isPreferredApiMongoDB ? "_id" : "id", isPartitionKey: false }
: { id: key, label: key, isPartitionKey: false },
);
if (showPartitionKey(_collection, isPreferredApiMongoDB)) {
columnDefinitions.push(
...partitionKeyPropertyHeaders.map((key) => ({ id: key, label: key, isPartitionKey: true })),
);
// Remove properties that are the partition keys, since they are already included
columnDefinitions = columnDefinitions.filter(
(columnDefinition) => !partitionKeyProperties.includes(columnDefinition.id),
);
}
return columnDefinitions;
};
/**
* Extract column definitions from document and add to the definitions
* @param document
*/
const setColumnDefinitionsFromDocument = (document: unknown): void => {
const currentIds = new Set(columnDefinitions.map((columnDefinition) => columnDefinition.id));
extractColumnDefinitionsFromDocument(document).forEach((columnDefinition) => {
if (!currentIds.has(columnDefinition.id)) {
columnDefinitions.push(columnDefinition);
}
});
setColumnDefinitions([...columnDefinitions]);
};
/** /**
* replicate logic of selectedDocument.click(); * replicate logic of selectedDocument.click();
* Document has been clicked on in table * Document has been clicked on in table
@@ -1330,6 +1414,9 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
(_isQueryCopilotSampleContainer ? readSampleDocument(documentId) : readDocument(_collection, documentId)).then( (_isQueryCopilotSampleContainer ? readSampleDocument(documentId) : readDocument(_collection, documentId)).then(
(content) => { (content) => {
initDocumentEditor(documentId, content); initDocumentEditor(documentId, content);
// Update columns
setColumnDefinitionsFromDocument(content);
}, },
); );
@@ -1420,10 +1507,22 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
return () => resizeObserver.disconnect(); // clean up return () => resizeObserver.disconnect(); // clean up
}, []); }, []);
const columnHeaders = { // Column definition is a map<id, ColumnDefinition> to garantee uniqueness
idHeader: isPreferredApiMongoDB ? "_id" : "id", const [columnDefinitions, setColumnDefinitions] = useState<ColumnDefinition[]>(() => {
partitionKeyHeaders: (showPartitionKey(_collection, isPreferredApiMongoDB) && partitionKeyPropertyHeaders) || [], const persistedColumnsSelection = readSubComponentState<ColumnsSelection>(
}; SubComponentName.ColumnsSelection,
_collection,
undefined,
);
if (!persistedColumnsSelection) {
return extractColumnDefinitionsFromDocument({
id: "id",
});
}
return persistedColumnsSelection.columnDefinitions;
});
const onSelectedRowsChange = (selectedRows: Set<TableRowId>) => { const onSelectedRowsChange = (selectedRows: Set<TableRowId>) => {
confirmDiscardingChange(() => { confirmDiscardingChange(() => {
@@ -1655,7 +1754,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
setIsExecuting(true); setIsExecuting(true);
onExecutionErrorChange(false); onExecutionErrorChange(false);
const filter: string = filterContent.trim(); const filter: string = filterContent.trim();
const query: string = buildQuery(isPreferredApiMongoDB, filter); const query: string = buildQuery(isPreferredApiMongoDB, filter, selectedColumnIds);
return MongoProxyClient.queryDocuments( return MongoProxyClient.queryDocuments(
_collection.databaseId, _collection.databaseId,
@@ -1721,7 +1820,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
const limitedLastFilterContents = lastFilterContents.slice(0, MAX_FILTER_HISTORY_COUNT); const limitedLastFilterContents = lastFilterContents.slice(0, MAX_FILTER_HISTORY_COUNT);
setLastFilterContents(limitedLastFilterContents); setLastFilterContents(limitedLastFilterContents);
saveSubComponentState(SubComponentName.FilterHistory, _collection, lastFilterContents); saveSubComponentState<FilterHistory>(SubComponentName.FilterHistory, _collection, lastFilterContents);
}; };
const refreshDocumentsGrid = useCallback( const refreshDocumentsGrid = useCallback(
@@ -1754,6 +1853,41 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
[createIterator, filterContent], [createIterator, filterContent],
); );
const onColumnSelectionChange = (newSelectedColumnIds: string[]): void => {
// Do not allow to unselecting all columns
if (newSelectedColumnIds.length === 0) {
return;
}
setSelectedColumnIds(newSelectedColumnIds);
saveSubComponentState<ColumnsSelection>(SubComponentName.ColumnsSelection, _collection, {
selectedColumnIds: newSelectedColumnIds,
columnDefinitions,
});
};
const prevSelectedColumnIds = usePrevious({ selectedColumnIds, setSelectedColumnIds });
useEffect(() => {
// If we are adding a field, let's refresh to include the field in the query
let addedField = false;
for (const field of selectedColumnIds) {
if (
!defaultQueryFields.includes(field) &&
prevSelectedColumnIds &&
!prevSelectedColumnIds.selectedColumnIds.includes(field)
) {
addedField = true;
break;
}
}
if (addedField) {
refreshDocumentsGrid(false);
}
}, [prevSelectedColumnIds, refreshDocumentsGrid, selectedColumnIds]);
return ( return (
<CosmosFluentProvider className={styles.container}> <CosmosFluentProvider className={styles.container}>
<div className="tab-pane active" role="tabpanel" style={{ display: "flex" }}> <div className="tab-pane active" role="tabpanel" style={{ display: "flex" }}>
@@ -1848,42 +1982,40 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
<Allotment <Allotment
onDragEnd={(sizes: number[]) => { onDragEnd={(sizes: number[]) => {
tabStateData.leftPaneWidthPercent = (100 * sizes[0]) / (sizes[0] + sizes[1]); tabStateData.leftPaneWidthPercent = (100 * sizes[0]) / (sizes[0] + sizes[1]);
saveSubComponentState(SubComponentName.MainTabDivider, _collection, tabStateData); saveSubComponentState<TabDivider>(SubComponentName.MainTabDivider, _collection, tabStateData);
setTabStateData(tabStateData); setTabStateData(tabStateData);
}} }}
> >
<Allotment.Pane preferredSize={`${tabStateData.leftPaneWidthPercent}%`} minSize={55}> <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.tableContainer}>
<div className={styles.floatingControls}> <div
<Button style={
appearance="transparent" {
aria-label="Refresh" height: "100%",
size="small" width: `calc(100% + ${calculateOffset(selectedColumnIds.length)}px)`,
icon={<ArrowClockwise16Filled />} } /* Fix to make table not resize beyond parent's width */
style={{ }
color: StyleConstants.AccentMedium, >
}} <DocumentsTableComponent
onClick={() => refreshDocumentsGrid(false)} onRefreshTable={() => refreshDocumentsGrid(false)}
onKeyDown={onRefreshKeyInput} items={tableItems}
onItemClicked={(index) => onDocumentClicked(index, documentIds)}
onSelectedRowsChange={onSelectedRowsChange}
selectedRows={selectedRows}
size={tableContainerSizePx}
selectedColumnIds={selectedColumnIds}
columnDefinitions={columnDefinitions}
isRowSelectionDisabled={
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
}
onColumnSelectionChange={onColumnSelectionChange}
defaultColumnSelection={getInitialColumnSelection()}
collection={_collection}
isColumnSelectionDisabled={isPreferredApiMongoDB}
/> />
</div> </div>
</div> </div>
<div className={styles.tableContainer}>
<DocumentsTableComponent
items={tableItems}
onItemClicked={(index) => onDocumentClicked(index, documentIds)}
onSelectedRowsChange={onSelectedRowsChange}
selectedRows={selectedRows}
size={tableContainerSizePx}
columnHeaders={columnHeaders}
isSelectionDisabled={
(partitionKey.systemKey && !isPreferredApiMongoDB) ||
(configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly)
}
collection={_collection}
/>
</div>
{tableItems.length > 0 && ( {tableItems.length > 0 && (
<a <a
className={styles.loadMore} className={styles.loadMore}

View File

@@ -21,15 +21,19 @@ describe("DocumentsTableComponent", () => {
height: 0, height: 0,
width: 0, width: 0,
}, },
columnHeaders: { columnDefinitions: [
idHeader: ID_HEADER, { id: ID_HEADER, label: "ID", isPartitionKey: false },
partitionKeyHeaders: [PARTITION_KEY_HEADER], { id: PARTITION_KEY_HEADER, label: "Partition Key", isPartitionKey: true },
}, ],
isSelectionDisabled: false, isRowSelectionDisabled: false,
collection: { collection: {
databaseId: "db", databaseId: "db",
id: ((): string => "coll") as ko.Observable<string>, id: ((): string => "coll") as ko.Observable<string>,
} as ViewModels.CollectionBase, } as ViewModels.CollectionBase,
onRefreshTable: (): void => {
throw new Error("Function not implemented.");
},
selectedColumnIds: [],
}); });
it("should render documents and partition keys in header", () => { it("should render documents and partition keys in header", () => {
@@ -40,7 +44,7 @@ describe("DocumentsTableComponent", () => {
it("should not render selection column when isSelectionDisabled is true", () => { it("should not render selection column when isSelectionDisabled is true", () => {
const props: IDocumentsTableComponentProps = createMockProps(); const props: IDocumentsTableComponentProps = createMockProps();
props.isSelectionDisabled = true; props.isRowSelectionDisabled = true;
const wrapper = mount(<DocumentsTableComponent {...props} />); const wrapper = mount(<DocumentsTableComponent {...props} />);
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });

View File

@@ -1,30 +1,48 @@
import { import {
createTableColumn, Button,
Menu, Menu,
MenuDivider,
MenuItem, MenuItem,
MenuList, MenuList,
MenuPopover, MenuPopover,
MenuTrigger, MenuTrigger,
TableRowData as RowStateBase, TableRowData as RowStateBase,
SortDirection,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableCellLayout, TableCellLayout,
TableColumnDefinition, TableColumnDefinition,
TableColumnId,
TableColumnSizingOptions, TableColumnSizingOptions,
TableHeader, TableHeader,
TableHeaderCell, TableHeaderCell,
TableRow, TableRow,
TableRowId, TableRowId,
TableSelectionCell, TableSelectionCell,
tokens,
useArrowNavigationGroup, useArrowNavigationGroup,
useTableColumnSizing_unstable, useTableColumnSizing_unstable,
useTableFeatures, useTableFeatures,
useTableSelection, useTableSelection,
useTableSort,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import {
ArrowClockwise16Regular,
ArrowResetRegular,
DeleteRegular,
EditRegular,
MoreHorizontalRegular,
TableResizeColumnRegular,
TextSortAscendingRegular,
TextSortDescendingRegular,
} from "@fluentui/react-icons";
import { NormalizedEventKey } from "Common/Constants"; import { NormalizedEventKey } from "Common/Constants";
import { TableColumnSelectionPane } from "Explorer/Panes/TableColumnSelectionPane/TableColumnSelectionPane";
import { import {
ColumnSizesMap, ColumnSizesMap,
ColumnSort,
deleteSubComponentState,
readSubComponentState, readSubComponentState,
saveSubComponentState, saveSubComponentState,
SubComponentName, SubComponentName,
@@ -32,29 +50,37 @@ import {
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 { userContext } from "UserContext";
import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils"; import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils";
import { useSidePanel } from "hooks/useSidePanel";
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"; import * as ViewModels from "../../../Contracts/ViewModels";
export type DocumentsTableComponentItem = { export type DocumentsTableComponentItem = {
id: string; id: string;
} & Record<string, string>; } & Record<string, string | number>;
export type ColumnHeaders = { export type ColumnDefinition = {
idHeader: string; id: string;
partitionKeyHeaders: string[]; label: string;
isPartitionKey: boolean;
}; };
export interface IDocumentsTableComponentProps { export interface IDocumentsTableComponentProps {
onRefreshTable: () => void;
items: DocumentsTableComponentItem[]; items: DocumentsTableComponentItem[];
onItemClicked: (index: number) => void; onItemClicked: (index: number) => void;
onSelectedRowsChange: (selectedItemsIndices: Set<TableRowId>) => void; onSelectedRowsChange: (selectedItemsIndices: Set<TableRowId>) => void;
selectedRows: Set<TableRowId>; selectedRows: Set<TableRowId>;
size: { height: number; width: number }; size: { height: number; width: number };
columnHeaders: ColumnHeaders; selectedColumnIds: string[];
columnDefinitions: ColumnDefinition[];
style?: React.CSSProperties; style?: React.CSSProperties;
isSelectionDisabled?: boolean; isRowSelectionDisabled?: boolean;
collection: ViewModels.CollectionBase; collection: ViewModels.CollectionBase;
onColumnSelectionChange?: (newSelectedColumnIds: string[]) => void;
defaultColumnSelection?: string[];
isColumnSelectionDisabled?: boolean;
} }
interface TableRowData extends RowStateBase<DocumentsTableComponentItem> { interface TableRowData extends RowStateBase<DocumentsTableComponentItem> {
@@ -67,25 +93,33 @@ interface ReactWindowRenderFnProps extends ListChildComponentProps {
data: TableRowData[]; data: TableRowData[];
} }
const COLUMNS_MENU_NAME = "columnsMenu";
const defaultSize = { const defaultSize = {
idealWidth: 200, idealWidth: 200,
minWidth: 50, minWidth: 50,
}; };
export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({
onRefreshTable,
items, items,
onSelectedRowsChange, onSelectedRowsChange,
selectedRows, selectedRows,
style, style,
size, size,
columnHeaders, selectedColumnIds,
isSelectionDisabled, columnDefinitions,
isRowSelectionDisabled: isSelectionDisabled,
collection, collection,
onColumnSelectionChange,
defaultColumnSelection,
isColumnSelectionDisabled,
}: IDocumentsTableComponentProps) => { }: IDocumentsTableComponentProps) => {
const styles = useDocumentsTabStyles();
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(() => { const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(() => {
const columnIds = ["id"].concat(columnHeaders.partitionKeyHeaders);
const columnSizesMap: ColumnSizesMap = readSubComponentState(SubComponentName.ColumnSizes, collection, {}); const columnSizesMap: ColumnSizesMap = readSubComponentState(SubComponentName.ColumnSizes, collection, {});
const columnSizesPx: TableColumnSizingOptions = {}; const columnSizesPx: TableColumnSizingOptions = {};
columnIds.forEach((columnId) => { selectedColumnIds.forEach((columnId) => {
if ( if (
!columnSizesMap || !columnSizesMap ||
!columnSizesMap[columnId] || !columnSizesMap[columnId] ||
@@ -103,7 +137,24 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
return columnSizesPx; return columnSizesPx;
}); });
const styles = useDocumentsTabStyles(); const [sortState, setSortState] = React.useState<{
sortDirection: "ascending" | "descending";
sortColumn: TableColumnId | undefined;
}>(() => {
const sort = readSubComponentState<ColumnSort>(SubComponentName.ColumnSort, collection, undefined);
if (!sort) {
return {
sortDirection: undefined,
sortColumn: undefined,
};
}
return {
sortDirection: sort.direction,
sortColumn: sort.columnId,
};
});
const onColumnResize = React.useCallback((_, { columnId, width }: { columnId: string; width: number }) => { const onColumnResize = React.useCallback((_, { columnId, width }: { columnId: string; width: number }) => {
setColumnSizingOptions((state) => { setColumnSizingOptions((state) => {
@@ -122,42 +173,123 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
return acc; return acc;
}, {} as ColumnSizesMap); }, {} as ColumnSizesMap);
saveSubComponentState(SubComponentName.ColumnSizes, collection, persistentSizes, true); saveSubComponentState<ColumnSizesMap>(SubComponentName.ColumnSizes, collection, persistentSizes, true);
return newSizingOptions; return newSizingOptions;
}); });
}, []); }, []);
// const restoreFocusTargetAttribute = useRestoreFocusTarget();
const onSortClick = (event: React.SyntheticEvent, columnId: string, direction: SortDirection) => {
setColumnSort(event, columnId, direction);
if (columnId === undefined || direction === undefined) {
deleteSubComponentState(SubComponentName.ColumnSort, collection);
return;
}
saveSubComponentState<ColumnSort>(SubComponentName.ColumnSort, collection, { columnId, direction });
};
// 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
const columns: TableColumnDefinition<DocumentsTableComponentItem>[] = useMemo( const columns: TableColumnDefinition<DocumentsTableComponentItem>[] = useMemo(
() => () =>
[ columnDefinitions
createTableColumn<DocumentsTableComponentItem>({ .filter((column) => selectedColumnIds.includes(column.id))
columnId: "id", .map((column) => ({
compare: (a, b) => a.id.localeCompare(b.id), columnId: column.id,
renderHeaderCell: () => columnHeaders.idHeader, compare: (a, b) => {
if (typeof a[column.id] === "string") {
return (a[column.id] as string).localeCompare(b[column.id] as string);
} else if (typeof a[column.id] === "number") {
return (a[column.id] as number) - (b[column.id] as number);
} else {
// Should not happen
return 0;
}
},
renderHeaderCell: () => (
<>
<span title={column.label}>{column.label}</span>
<Menu>
<MenuTrigger disableButtonEnhancement>
<Button
// {...restoreFocusTargetAttribute}
appearance="transparent"
aria-label="Select column"
size="small"
icon={<MoreHorizontalRegular />}
style={{ position: "absolute", right: 0, backgroundColor: tokens.colorNeutralBackground1 }}
/>
</MenuTrigger>
<MenuPopover>
<MenuList>
<MenuItem key="refresh" icon={<ArrowClockwise16Regular />} onClick={onRefreshTable}>
Refresh
</MenuItem>
{userContext.features.enableDocumentsTableColumnSelection && (
<>
<MenuItem
icon={<TextSortAscendingRegular />}
onClick={(e) => onSortClick(e, column.id, "ascending")}
>
Sort ascending
</MenuItem>
<MenuItem
icon={<TextSortDescendingRegular />}
onClick={(e) => onSortClick(e, column.id, "descending")}
>
Sort descending
</MenuItem>
<MenuItem icon={<ArrowResetRegular />} onClick={(e) => onSortClick(e, undefined, undefined)}>
Reset sorting
</MenuItem>
{!isColumnSelectionDisabled && (
<MenuItem key="editcolumns" icon={<EditRegular />} onClick={openColumnSelectionPane}>
Edit columns
</MenuItem>
)}
<MenuDivider />
<MenuItem
key="keyboardresize"
icon={<TableResizeColumnRegular />}
onClick={columnSizing.enableKeyboardMode(column.id)}
>
Resize with left/right arrow keys
</MenuItem>
{!isColumnSelectionDisabled && (
<MenuItem
key="remove"
icon={<DeleteRegular />}
onClick={() => {
// Remove column id from selectedColumnIds
const index = selectedColumnIds.indexOf(column.id);
if (index === -1) {
return;
}
const newSelectedColumnIds = [...selectedColumnIds];
newSelectedColumnIds.splice(index, 1);
onColumnSelectionChange(newSelectedColumnIds);
}}
>
Remove column
</MenuItem>
)}
</>
)}
</MenuList>
</MenuPopover>
</Menu>
</>
),
renderCell: (item) => ( renderCell: (item) => (
<TableCellLayout truncate title={item.id}> <TableCellLayout truncate title={`${item[column.id]}`}>
{item.id} {item[column.id]}
</TableCellLayout> </TableCellLayout>
), ),
}), })),
].concat( [columnDefinitions, onColumnSelectionChange, selectedColumnIds],
columnHeaders.partitionKeyHeaders.map((pkHeader) =>
createTableColumn<DocumentsTableComponentItem>({
columnId: pkHeader,
compare: (a, b) => a[pkHeader].localeCompare(b[pkHeader]),
// Show Refresh button on last column
renderHeaderCell: () => <span title={pkHeader}>{pkHeader}</span>,
renderCell: (item) => (
<TableCellLayout truncate title={item[pkHeader]}>
{item[pkHeader]}
</TableCellLayout>
),
}),
),
),
[columnHeaders],
); );
const [selectionStartIndex, setSelectionStartIndex] = React.useState<number>(INITIAL_SELECTED_ROW_INDEX); const [selectionStartIndex, setSelectionStartIndex] = React.useState<number>(INITIAL_SELECTED_ROW_INDEX);
@@ -247,6 +379,7 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
columnSizing_unstable: columnSizing, columnSizing_unstable: columnSizing,
tableRef, tableRef,
selection: { allRowsSelected, someRowsSelected, toggleAllRows, toggleRow, isRowSelected }, selection: { allRowsSelected, someRowsSelected, toggleAllRows, toggleRow, isRowSelected },
sort: { getSortDirection, setColumnSort, sort },
} = useTableFeatures( } = useTableFeatures(
{ {
columns, columns,
@@ -260,25 +393,36 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
// eslint-disable-next-line react/prop-types // eslint-disable-next-line react/prop-types
onSelectionChange: (e, data) => onSelectedRowsChange(data.selectedItems), onSelectionChange: (e, data) => onSelectedRowsChange(data.selectedItems),
}), }),
useTableSort({
sortState,
onSortChange: (e, nextSortState) => setSortState(nextSortState),
}),
], ],
); );
const rows: TableRowData[] = getRows((row) => { const headerSortProps = (columnId: TableColumnId) => ({
const selected = isRowSelected(row.rowId); // onClick: (e: React.MouseEvent) => toggleColumnSort(e, columnId),
return { sortDirection: getSortDirection(columnId),
...row,
onClick: (e: React.MouseEvent) => toggleRow(e, row.rowId),
onKeyDown: (e: React.KeyboardEvent) => {
if (e.key === " ") {
e.preventDefault();
toggleRow(e, row.rowId);
}
},
selected,
appearance: selected ? ("brand" as const) : ("none" as const),
};
}); });
const rows: TableRowData[] = sort(
getRows((row) => {
const selected = isRowSelected(row.rowId);
return {
...row,
onClick: (e: React.MouseEvent) => toggleRow(e, row.rowId),
onKeyDown: (e: React.KeyboardEvent) => {
if (e.key === " ") {
e.preventDefault();
toggleRow(e, row.rowId);
}
},
selected,
appearance: selected ? ("brand" as const) : ("none" as const),
};
}),
);
const toggleAllKeydown = React.useCallback( const toggleAllKeydown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => { (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === " ") { if (e.key === " ") {
@@ -304,37 +448,50 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
...style, ...style,
}; };
const checkedValues: { [COLUMNS_MENU_NAME]: string[] } = {
[COLUMNS_MENU_NAME]: [],
};
columnDefinitions.forEach(
(columnDefinition) =>
selectedColumnIds.includes(columnDefinition.id) && checkedValues[COLUMNS_MENU_NAME].push(columnDefinition.id),
);
const openColumnSelectionPane = (): void => {
useSidePanel
.getState()
.openSidePanel(
"Select columns",
<TableColumnSelectionPane
selectedColumnIds={selectedColumnIds}
columnDefinitions={columnDefinitions}
onSelectionChange={onColumnSelectionChange}
defaultSelection={defaultColumnSelection}
/>,
);
};
return ( return (
<Table noNativeElements {...tableProps}> <Table noNativeElements sortable {...tableProps}>
<TableHeader> <TableHeader>
<TableRow className={styles.tableRow} style={{ width: size ? size.width - 15 : "100%" }}> <TableRow className={styles.tableRow} style={{ width: size ? size.width - 15 : "100%" }}>
{!isSelectionDisabled && ( {!isSelectionDisabled && (
<TableSelectionCell <TableSelectionCell
key="selectcell"
checked={allRowsSelected ? true : someRowsSelected ? "mixed" : false} checked={allRowsSelected ? true : someRowsSelected ? "mixed" : false}
onClick={toggleAllRows} onClick={toggleAllRows}
onKeyDown={toggleAllKeydown} onKeyDown={toggleAllKeydown}
checkboxIndicator={{ "aria-label": "Select all rows " }} checkboxIndicator={{ "aria-label": "Select all rows " }}
/> />
)} )}
{columns.map((column /* index */) => ( {columns.map((column) => (
<Menu openOnContext key={column.columnId}> <TableHeaderCell
<MenuTrigger> className={styles.tableCell}
<TableHeaderCell key={column.columnId}
className={styles.tableCell} {...columnSizing.getTableHeaderCellProps(column.columnId)}
key={column.columnId} {...headerSortProps(column.columnId)}
{...columnSizing.getTableHeaderCellProps(column.columnId)} >
> {column.renderHeaderCell()}
{column.renderHeaderCell()} </TableHeaderCell>
</TableHeaderCell>
</MenuTrigger>
<MenuPopover>
<MenuList>
<MenuItem onClick={columnSizing.enableKeyboardMode(column.columnId)}>
Keyboard Column Resizing
</MenuItem>
</MenuList>
</MenuPopover>
</Menu>
))} ))}
</TableRow> </TableRow>
</TableHeader> </TableHeader>

View File

@@ -1,3 +1,5 @@
import { useEffect, useRef } from "react";
/** /**
* Utility class to help with selection. * Utility class to help with selection.
* This emulates File Explorer selection behavior. * This emulates File Explorer selection behavior.
@@ -90,3 +92,12 @@ export const selectionHelper = (
} }
} }
}; };
// To get previous values of a state in useEffect
export const usePrevious = <T>(value: T): T | undefined => {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
});
return ref.current;
};

View File

@@ -55,53 +55,57 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
} }
> >
<div <div
className="___77lcry0_0000000 f10pi13n" className="___9o87uj0_0000000 ffefeo0"
> >
<div <div
className="___1rwkz4r_0000000 f1euv43f f1l8gmrm f1e31b4d f150nix6 fy6ml6n f19g0ac" style={
{
"height": "100%",
"width": "calc(100% + -13px)",
}
}
> >
<Button <DocumentsTableComponent
appearance="transparent" collection={
aria-label="Refresh"
icon={<ArrowClockwise16Filled />}
onClick={[Function]}
onKeyDown={[Function]}
size="small"
style={
{ {
"color": undefined, "databaseId": "databaseId",
"id": [Function],
}
}
columnDefinitions={
[
{
"id": "id",
"isPartitionKey": false,
"label": "id",
},
]
}
defaultColumnSelection={
[
"id",
]
}
isColumnSelectionDisabled={false}
isRowSelectionDisabled={true}
items={[]}
onColumnSelectionChange={[Function]}
onItemClicked={[Function]}
onRefreshTable={[Function]}
onSelectedRowsChange={[Function]}
selectedColumnIds={
[
"id",
]
}
selectedRows={
Set {
0,
} }
} }
/> />
</div> </div>
</div> </div>
<div
className="___9o87uj0_0000000 ffefeo0"
>
<DocumentsTableComponent
collection={
{
"databaseId": "databaseId",
"id": [Function],
}
}
columnHeaders={
{
"idHeader": "id",
"partitionKeyHeaders": [],
}
}
isSelectionDisabled={true}
items={[]}
onItemClicked={[Function]}
onSelectedRowsChange={[Function]}
selectedRows={
Set {
0,
}
}
/>
</div>
</div> </div>
</Allotment.Pane> </Allotment.Pane>
<Allotment.Pane <Allotment.Pane

View File

@@ -38,6 +38,7 @@ export type Features = {
readonly copilotChatFixedMonacoEditorHeight: boolean; readonly copilotChatFixedMonacoEditorHeight: boolean;
readonly enablePriorityBasedExecution: boolean; readonly enablePriorityBasedExecution: boolean;
readonly disableConnectionStringLogin: boolean; readonly disableConnectionStringLogin: boolean;
readonly enableDocumentsTableColumnSelection: boolean;
// can be set via both flight and feature flag // can be set via both flight and feature flag
autoscaleDefault: boolean; autoscaleDefault: boolean;
@@ -108,6 +109,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear
copilotChatFixedMonacoEditorHeight: "true" === get("copilotchatfixedmonacoeditorheight"), copilotChatFixedMonacoEditorHeight: "true" === get("copilotchatfixedmonacoeditorheight"),
enablePriorityBasedExecution: "true" === get("enableprioritybasedexecution"), enablePriorityBasedExecution: "true" === get("enableprioritybasedexecution"),
disableConnectionStringLogin: "true" === get("disableconnectionstringlogin"), disableConnectionStringLogin: "true" === get("disableconnectionstringlogin"),
enableDocumentsTableColumnSelection: "true" === get("enabledocumentstablecolumnselection"),
}; };
} }

View File

@@ -29,6 +29,7 @@ export enum StorageKey {
GalleryCalloutDismissed, GalleryCalloutDismissed,
VisitedAccounts, VisitedAccounts,
PriorityLevel, PriorityLevel,
DocumentsTabPrefs,
DefaultQueryResultsView, DefaultQueryResultsView,
AppState, AppState,
} }

View File

@@ -2,18 +2,28 @@ import { PartitionKey, PartitionKeyDefinition } from "@azure/cosmos";
import * as DataModels from "../Contracts/DataModels"; import * as DataModels from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels"; import * as ViewModels from "../Contracts/ViewModels";
export const defaultQueryFields = ["id", "_self", "_rid", "_ts"];
export function buildDocumentsQuery( export function buildDocumentsQuery(
filter: string, filter: string,
partitionKeyProperties: string[], partitionKeyProperties: string[],
partitionKey: DataModels.PartitionKey, partitionKey: DataModels.PartitionKey,
additionalField: string[] = [],
): string { ): string {
const fieldSet = new Set<string>(defaultQueryFields);
additionalField.forEach((prop) => fieldSet.add(prop));
const objectListSpec = [...fieldSet]
.filter((f) => !partitionKeyProperties.includes(f))
.map((prop) => `c.${prop}`)
.join(",");
let query = let query =
partitionKeyProperties && partitionKeyProperties.length > 0 partitionKeyProperties && partitionKeyProperties.length > 0
? `select c.id, c._self, c._rid, c._ts, [${buildDocumentsQueryPartitionProjections( ? `select ${objectListSpec}, [${buildDocumentsQueryPartitionProjections(
"c", "c",
partitionKey, partitionKey,
)}] as _partitionKeyValue from c` )}] as _partitionKeyValue from c`
: `select c.id, c._self, c._rid, c._ts from c`; : `select ${objectListSpec} from c`;
if (filter) { if (filter) {
query += " " + filter; query += " " + filter;