Enable column selection and sorting in DocumentsTab (with persistence) with improvements (#1963)

* Reapply "Enable column selection and sorting in DocumentsTab (with persistence) (#1881)" (#1960)

This reverts commit fe9730206e.

* Fix logic bug: always include defaultQueryFields in query.

* Show resize column option outside of feature flag

* Improve prevention of no selected columns

* Add more unit tests

* Fix styling on table

* Update test snapshots

* Remove "sortable" property on table which makes the header cell focusable (user sorts by selecting menu item, not by clicking on cell)
This commit is contained in:
Laurent Nguyen 2024-09-11 13:26:49 +02:00 committed by GitHub
parent d75553a94d
commit 825a5d5257
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 916 additions and 1466 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,156 @@
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 {
/* selectedColumnIds may contain ids that are not in columnDefinitions, because the selected
* ids may have been loaded from persistence, but don't exist in the current retrieved documents.
*/
if (
Array.from(selectedColumnIdsSet).filter((id) => columnDefinitions.find((def) => def.id === id) !== undefined)
.length === 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
@ -89,6 +91,13 @@ export const useDocumentsTabStyles = makeStyles({
tableCell: { tableCell: {
...cosmosShorthands.borderLeft(), ...cosmosShorthands.borderLeft(),
}, },
tableHeader: {
display: "flex",
},
tableHeaderFiller: {
width: "20px",
boxShadow: `0px -1px ${tokens.colorNeutralStroke2} inset`,
},
loadMore: { loadMore: {
...cosmosShorthands.borderTop(), ...cosmosShorthands.borderTop(),
display: "grid", display: "grid",
@ -101,17 +110,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 +279,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 +467,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 +536,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 - 27;
// 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 +560,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 +591,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 +605,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 +655,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 +695,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
}, },
rawDocument, rawDocument,
partitionKeyValue, partitionKeyValue,
), ) as ExtendedDocumentId;
extendedDocumentId.tableFields = { ...rawDocument };
return extendedDocumentId;
},
[partitionKey], [partitionKey],
); );
@ -810,6 +860,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 +946,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
}, },
startKey, startKey,
); );
// Update column choices
selectedDocumentId.tableFields = { ...documentContent };
setColumnDefinitionsFromDocument(documentContent);
}, },
(error) => { (error) => {
onExecutionErrorChange(true); onExecutionErrorChange(true);
@ -1103,7 +1161,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'.
@ -1126,6 +1190,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
resourceTokenPartitionKey, resourceTokenPartitionKey,
isQueryCopilotSampleContainer, isQueryCopilotSampleContainer,
_collection, _collection,
selectedColumnIds,
]); ]);
const onHideFilterClick = (): void => { const onHideFilterClick = (): void => {
@ -1271,16 +1336,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;
@ -1312,9 +1367,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++) {
@ -1325,6 +1378,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
@ -1340,6 +1431,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);
}, },
); );
@ -1430,10 +1524,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(() => {
@ -1665,7 +1771,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,
@ -1731,7 +1837,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(
@ -1764,6 +1870,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]);
// TODO: remove isMongoBulkDeleteDisabled when new mongo proxy is enabled for all users // TODO: remove isMongoBulkDeleteDisabled when new mongo proxy is enabled for all users
// TODO: remove partitionKey.systemKey when JS SDK bug is fixed // TODO: remove partitionKey.systemKey when JS SDK bug is fixed
const isMongoBulkDeleteDisabled = !MongoProxyClient.useMongoProxyEndpoint("bulkdelete"); const isMongoBulkDeleteDisabled = !MongoProxyClient.useMongoProxyEndpoint("bulkdelete");
@ -1865,42 +2006,41 @@ 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.floatingControls}>
<Button
appearance="transparent"
aria-label="Refresh"
size="small"
icon={<ArrowClockwise16Filled />}
style={{
color: StyleConstants.AccentMedium,
}}
onClick={() => refreshDocumentsGrid(false)}
onKeyDown={onRefreshKeyInput}
/>
</div>
</div>
<div className={styles.tableContainer}> <div className={styles.tableContainer}>
<div
style={
{
height: "100%",
width: `calc(100% + ${calculateOffset(selectedColumnIds.length)}px)`,
} /* Fix to make table not resize beyond parent's width */
}
>
<DocumentsTableComponent <DocumentsTableComponent
onRefreshTable={() => refreshDocumentsGrid(false)}
items={tableItems} items={tableItems}
onItemClicked={(index) => onDocumentClicked(index, documentIds)} onItemClicked={(index) => onDocumentClicked(index, documentIds)}
onSelectedRowsChange={onSelectedRowsChange} onSelectedRowsChange={onSelectedRowsChange}
selectedRows={selectedRows} selectedRows={selectedRows}
size={tableContainerSizePx} size={tableContainerSizePx}
columnHeaders={columnHeaders} selectedColumnIds={selectedColumnIds}
isSelectionDisabled={ columnDefinitions={columnDefinitions}
isRowSelectionDisabled={
isBulkDeleteDisabled || isBulkDeleteDisabled ||
(configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly) (configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly)
} }
onColumnSelectionChange={onColumnSelectionChange}
defaultColumnSelection={getInitialColumnSelection()}
collection={_collection} collection={_collection}
isColumnSelectionDisabled={isPreferredApiMongoDB}
/> />
</div> </div>
</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>
{userContext.features.enableDocumentsTableColumnSelection && !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,10 +393,20 @@ 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) => ({
// onClick: (e: React.MouseEvent) => toggleColumnSort(e, columnId),
sortDirection: getSortDirection(columnId),
});
const rows: TableRowData[] = sort(
getRows((row) => {
const selected = isRowSelected(row.rowId); const selected = isRowSelected(row.rowId);
return { return {
...row, ...row,
@ -277,7 +420,8 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
selected, selected,
appearance: selected ? ("brand" as const) : ("none" as const), appearance: selected ? ("brand" as const) : ("none" as const),
}; };
}); }),
);
const toggleAllKeydown = React.useCallback( const toggleAllKeydown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => { (e: React.KeyboardEvent<HTMLDivElement>) => {
@ -304,39 +448,53 @@ 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 {...tableProps}>
<TableHeader> <TableHeader className={styles.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}>
<MenuTrigger>
<TableHeaderCell <TableHeaderCell
className={styles.tableCell} className={styles.tableCell}
key={column.columnId} key={column.columnId}
{...columnSizing.getTableHeaderCellProps(column.columnId)} {...columnSizing.getTableHeaderCellProps(column.columnId)}
{...headerSortProps(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>
<div className={styles.tableHeaderFiller}></div>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<List <List

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,28 +55,15 @@ 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"
>
<Button
appearance="transparent"
aria-label="Refresh"
icon={<ArrowClockwise16Filled />}
onClick={[Function]}
onKeyDown={[Function]}
size="small"
style={ style={
{ {
"color": undefined, "height": "100%",
"width": "calc(100% + -11px)",
} }
} }
/>
</div>
</div>
<div
className="___9o87uj0_0000000 ffefeo0"
> >
<DocumentsTableComponent <DocumentsTableComponent
collection={ collection={
@ -85,16 +72,32 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
"id": [Function], "id": [Function],
} }
} }
columnHeaders={ columnDefinitions={
[
{ {
"idHeader": "id", "id": "id",
"partitionKeyHeaders": [], "isPartitionKey": false,
"label": "id",
},
]
} }
defaultColumnSelection={
[
"id",
]
} }
isSelectionDisabled={true} isColumnSelectionDisabled={false}
isRowSelectionDisabled={true}
items={[]} items={[]}
onColumnSelectionChange={[Function]}
onItemClicked={[Function]} onItemClicked={[Function]}
onRefreshTable={[Function]}
onSelectedRowsChange={[Function]} onSelectedRowsChange={[Function]}
selectedColumnIds={
[
"id",
]
}
selectedRows={ selectedRows={
Set { Set {
0, 0,
@ -103,6 +106,7 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
/> />
</div> </div>
</div> </div>
</div>
</Allotment.Pane> </Allotment.Pane>
<Allotment.Pane <Allotment.Pane
minSize={30} minSize={30}

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

@ -4,7 +4,7 @@ import * as sinon from "sinon";
import * as DataModels from "../Contracts/DataModels"; import * as DataModels from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels"; import * as ViewModels from "../Contracts/ViewModels";
import * as QueryUtils from "./QueryUtils"; import * as QueryUtils from "./QueryUtils";
import { extractPartitionKeyValues } from "./QueryUtils"; import { defaultQueryFields, extractPartitionKeyValues } from "./QueryUtils";
describe("Query Utils", () => { describe("Query Utils", () => {
const generatePartitionKeyForPath = (path: string): DataModels.PartitionKey => { const generatePartitionKeyForPath = (path: string): DataModels.PartitionKey => {
@ -54,6 +54,20 @@ describe("Query Utils", () => {
expect(partitionProjection).toContain('c["\\\\\\"a\\\\\\""]'); expect(partitionProjection).toContain('c["\\\\\\"a\\\\\\""]');
}); });
it("should always include the default fields", () => {
const query: string = QueryUtils.buildDocumentsQuery("", [], generatePartitionKeyForPath("/a"), []);
defaultQueryFields.forEach((field) => {
expect(query).toContain(`c.${field}`);
});
});
it("should always include the default fields even if they are themselves partition key fields", () => {
const query: string = QueryUtils.buildDocumentsQuery("", ["id"], generatePartitionKeyForPath("/id"), ["id"]);
expect(query).toContain("c.id");
});
}); });
describe("queryPagesUntilContentPresent()", () => { describe("queryPagesUntilContentPresent()", () => {

View File

@ -2,18 +2,29 @@ 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) => {
if (!partitionKeyProperties.includes(prop)) {
fieldSet.add(prop);
}
});
const objectListSpec = [...fieldSet].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;